Skip to content

Commit f3c7883

Browse files
committed
Add zizmor, improve performance
1 parent 42ad689 commit f3c7883

8 files changed

Lines changed: 121 additions & 29 deletions

File tree

.github/dependabot.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ updates:
44
directory: "/"
55
schedule:
66
interval: "weekly"
7+
cooldown:
8+
default-days: 7
79
labels: []

.github/workflows/zizmor.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: GitHub Actions Security Analysis with zizmor 🌈
2+
3+
on: # zizmor: ignore[concurrency-limits]
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- '.github/**.yml'
9+
- 'action.yml'
10+
pull_request:
11+
paths:
12+
- '.github/**.yml'
13+
- 'action.yml'
14+
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
zizmor:
20+
name: Run zizmor 🌈
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
25+
with:
26+
persist-credentials: false
27+
28+
- name: Run zizmor 🌈
29+
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
30+
with:
31+
advanced-security: false
32+
annotations: true
33+
persona: 'pedantic'

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ concurrency:
7878
cancel-in-progress: true
7979
8080
jobs:
81-
comment:
81+
check-api-surface:
8282
runs-on: ubuntu-latest
8383
steps:
8484
- uses: composer/api-surface-check@main

action.yml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ runs:
9696
- name: Fetch base ref
9797
if: steps.pr.outputs.number
9898
shell: bash
99-
run: git fetch --no-tags --depth=1 origin "${{ steps.pr.outputs.base-ref }}:refs/remotes/origin/${{ steps.pr.outputs.base-ref }}"
99+
env:
100+
BASE_REF: ${{ steps.pr.outputs.base-ref }}
101+
run: git fetch --no-tags --depth=1 origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}"
100102

101103
- name: Preflight scan
102104
id: preflight
@@ -107,8 +109,9 @@ runs:
107109
BASE_REF: origin/${{ steps.pr.outputs.base-ref }}
108110
PATHS_GLOB: ${{ inputs.paths }}
109111
OUTPUT_FILE: api-surface-preflight.txt
112+
ACTION_PATH: ${{ github.action_path }}
110113
run: |
111-
bash ${{ github.action_path }}/scripts/preflight.sh
114+
bash "${ACTION_PATH}/scripts/preflight.sh"
112115
echo "should-run=$(cat api-surface-preflight.txt)" >> "$GITHUB_OUTPUT"
113116
114117
- name: Setup PHP
@@ -143,7 +146,7 @@ runs:
143146
exit 0
144147
fi
145148
if [[ -d vendor ]]; then
146-
echo "vendor-path=$(cd vendor && pwd)" >> "$GITHUB_OUTPUT"
149+
echo "project-path=$(pwd)" >> "$GITHUB_OUTPUT"
147150
fi
148151
149152
- name: Run analysis
@@ -155,7 +158,7 @@ runs:
155158
BASE_REF: origin/${{ steps.pr.outputs.base-ref }}
156159
PATHS_GLOB: ${{ inputs.paths }}
157160
SOURCE_ROOTS: ${{ inputs.source-roots }}
158-
VENDOR_PATH: ${{ steps.project-deps.outputs.vendor-path }}
161+
COMPOSER_PROJECT_PATH: ${{ steps.project-deps.outputs.project-path }}
159162
OUTPUT_DIR: ${{ inputs.output-dir }}
160163
INCLUDE_INTERNAL: ${{ inputs.include-internal }}
161164
TYPES: ${{ inputs.types }}
@@ -164,7 +167,8 @@ runs:
164167
SHOW_MODIFIED: ${{ inputs.show-modified }}
165168
COMMENT_MARKER: ${{ inputs.comment-marker }}
166169
HEADING: ${{ inputs.heading }}
167-
run: bash ${{ github.action_path }}/scripts/detect.sh
170+
ACTION_PATH: ${{ github.action_path }}
171+
run: bash "${ACTION_PATH}/scripts/detect.sh"
168172

169173
# Always run post-comment when we have a PR. With analysis skipped,
170174
# comment-body.txt is missing/empty and post-comment deletes any
@@ -177,4 +181,5 @@ runs:
177181
OUTPUT_DIR: ${{ inputs.output-dir }}
178182
COMMENT_MARKER: ${{ inputs.comment-marker }}
179183
PR_NUMBER: ${{ steps.pr.outputs.number }}
180-
run: bash ${{ github.action_path }}/scripts/post-comment.sh
184+
ACTION_PATH: ${{ github.action_path }}
185+
run: bash "${ACTION_PATH}/scripts/post-comment.sh"

scripts/detect.sh

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ INCLUDE_INTERNAL="${INCLUDE_INTERNAL:-false}"
1717
TYPES="${TYPES:-class,interface,trait,enum,method,property,constant}"
1818
VISIBILITY="${VISIBILITY:-public,protected}"
1919
SHOW_REMOVED="${SHOW_REMOVED:-true}"
20-
SHOW_MODIFIED="${SHOW_MODIFIED:-false}"
20+
SHOW_MODIFIED="${SHOW_MODIFIED:-true}"
2121
COMMENT_MARKER="${COMMENT_MARKER:-<!-- api-surface-bot -->}"
2222
HEADING="${HEADING:-## API Surface Changes}"
2323

@@ -58,16 +58,26 @@ fi
5858
echo "Analyzing ${#changed_files[@]} changed file(s)."
5959
files_csv=$(IFS=','; echo "${changed_files[*]}")
6060

61-
# When install-dependencies is on, the action exports VENDOR_PATH as an
62-
# absolute path to the project's vendor/. We append it to the source roots so
63-
# better-reflection can walk through into vendor parents on both HEAD and BASE
64-
# snapshots (BASE uses HEAD's vendor since dependencies aren't in git).
61+
# When install-dependencies is on, the action exports COMPOSER_PROJECT_PATH
62+
# pointing at a directory containing composer.json + vendor/composer/installed.json.
63+
# Snapshotter then resolves vendor parents through composer's PSR-4 mappings
64+
# (O(1) per FQCN) instead of recursive directory walks.
65+
#
66+
# VENDOR_PATH is supported as a fallback for environments / tests where vendor/
67+
# is laid out without a real composer.json — it appends the directory to the
68+
# source roots so DirectoriesSourceLocator can find classes (slow on real
69+
# vendor trees; intended for synthetic fixtures).
6570
roots_for_snapshot=("${roots_array[@]}")
6671
if [[ -n "${VENDOR_PATH:-}" && -d "${VENDOR_PATH}" ]]; then
6772
roots_for_snapshot+=("${VENDOR_PATH}")
6873
echo "Including vendor path for resolution: ${VENDOR_PATH}"
6974
fi
7075
roots_csv=$(IFS=','; echo "${roots_for_snapshot[*]}")
76+
composer_arg=()
77+
if [[ -n "${COMPOSER_PROJECT_PATH:-}" && -d "${COMPOSER_PROJECT_PATH}" ]]; then
78+
composer_arg=("--composer-path=${COMPOSER_PROJECT_PATH}")
79+
echo "Using composer project path for vendor resolution: ${COMPOSER_PROJECT_PATH}"
80+
fi
7181

7282
# Extract BASE source for parent/interface resolution and BASE-side reflection.
7383
base_tree="${OUTPUT_DIR}/.base-tree"
@@ -82,15 +92,17 @@ git archive "${BASE_REF}" -- "${roots_array[@]}" 2>/dev/null | tar -x -C "${base
8292
php "${SCRIPT_DIR}/snapshot.php" \
8393
--files="${files_csv}" \
8494
--roots="${roots_csv}" \
85-
--output="${OUTPUT_DIR}/head-snapshot.json"
95+
--output="${OUTPUT_DIR}/head-snapshot.json" \
96+
"${composer_arg[@]}"
8697

8798
# BASE snapshot — run from base-tree so relative paths match.
8899
(
89100
cd "${base_tree}"
90101
php "${SCRIPT_DIR}/snapshot.php" \
91102
--files="${files_csv}" \
92103
--roots="${roots_csv}" \
93-
--output="${OLDPWD}/${OUTPUT_DIR}/base-snapshot.json"
104+
--output="${OLDPWD}/${OUTPUT_DIR}/base-snapshot.json" \
105+
"${composer_arg[@]}"
94106
)
95107

96108
# Diff

scripts/snapshot.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* CLI wrapper around the Snapshotter.
77
*
88
* Usage:
9-
* php snapshot.php --files=<comma-or-newline-list> --roots=<comma-or-newline-list> --output=<path>
9+
* php snapshot.php --files=<list> --roots=<list> --output=<path> [--composer-path=<dir>]
1010
*/
1111

1212
require __DIR__ . '/../vendor/autoload.php';
@@ -15,21 +15,24 @@
1515

1616
$opts = parseArgs($argv);
1717

18-
$snapshotter = new Snapshotter($opts['roots']);
18+
$snapshotter = new Snapshotter($opts['roots'], $opts['composer-path']);
1919
$records = $snapshotter->snapshot($opts['files']);
2020

2121
file_put_contents($opts['output'], json_encode($records, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
2222

2323
function parseArgs(array $argv): array
2424
{
25-
$opts = ['files' => [], 'roots' => [], 'output' => null];
25+
$opts = ['files' => [], 'roots' => [], 'output' => null, 'composer-path' => null];
2626
foreach (array_slice($argv, 1) as $arg) {
2727
if (str_starts_with($arg, '--files=')) {
2828
$opts['files'] = splitList(substr($arg, 8));
2929
} elseif (str_starts_with($arg, '--roots=')) {
3030
$opts['roots'] = splitList(substr($arg, 8));
3131
} elseif (str_starts_with($arg, '--output=')) {
3232
$opts['output'] = substr($arg, 9);
33+
} elseif (str_starts_with($arg, '--composer-path=')) {
34+
$value = substr($arg, 16);
35+
$opts['composer-path'] = $value !== '' ? $value : null;
3336
}
3437
}
3538
if ($opts['output'] === null) {

src/Snapshotter.php

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@
3030
use Roave\BetterReflection\Reflector\Exception\IdentifierNotFound;
3131
use Roave\BetterReflection\Reflector\Reflector;
3232
use Roave\BetterReflection\SourceLocator\Type\AggregateSourceLocator;
33+
use Roave\BetterReflection\SourceLocator\Type\Composer\Factory\Exception\MissingComposerJson;
34+
use Roave\BetterReflection\SourceLocator\Type\Composer\Factory\Exception\MissingInstalledJson;
35+
use Roave\BetterReflection\SourceLocator\Type\Composer\Factory\MakeLocatorForComposerJsonAndInstalledJson;
3336
use Roave\BetterReflection\SourceLocator\Type\DirectoriesSourceLocator;
37+
use Roave\BetterReflection\SourceLocator\Type\MemoizingSourceLocator;
3438
use Roave\BetterReflection\SourceLocator\Type\PhpInternalSourceLocator;
39+
use Roave\BetterReflection\SourceLocator\Type\SingleFileSourceLocator;
3540

3641
/**
3742
* Builds an array of API-surface symbol records for a set of files.
@@ -47,9 +52,11 @@ final class Snapshotter
4752
private Parser $parser;
4853

4954
/**
50-
* @param list<string> $sourceRoots Directories used to resolve parent classes / interfaces.
55+
* @param list<string> $sourceRoots Directories used to resolve parent classes / interfaces.
56+
* @param string|null $composerProjectPath Optional project root with composer.json + vendor/composer/installed.json. When set, parent / interface
57+
* lookups go through composer's PSR-4 mappings (O(1) per FQCN) instead of recursive directory scans.
5158
*/
52-
public function __construct(private array $sourceRoots)
59+
public function __construct(private array $sourceRoots, private ?string $composerProjectPath = null)
5360
{
5461
$this->prettyPrinter = new PrettyPrinter();
5562
$this->parser = (new ParserFactory())->createForHostVersion();
@@ -73,7 +80,7 @@ public function snapshot(array $files): array
7380
return [];
7481
}
7582

76-
$reflector = $this->buildReflector();
83+
$reflector = $this->buildReflector(array_keys($targetFiles));
7784
$records = [];
7885

7986
// Enumerate target classes by parsing each target file directly with
@@ -150,21 +157,50 @@ private function extractClassFqcns(string $absPath): array
150157
return $fqcns;
151158
}
152159

153-
private function buildReflector(): Reflector
160+
/**
161+
* @param list<string> $targetAbsPaths Absolute paths of files we'll be snapshotting (added at the front of the
162+
* aggregate so target FQCN lookups don't fall through to vendor / src walks).
163+
*/
164+
private function buildReflector(array $targetAbsPaths): Reflector
154165
{
155166
$br = new BetterReflection();
156167
$astLocator = $br->astLocator();
157168
$stubber = $br->sourceStubber();
158169

159170
$locators = [];
171+
172+
// Target files first: lookups for classes declared in changed files
173+
// resolve in O(1) without falling through to the slower DirectoriesSourceLocator.
174+
foreach ($targetAbsPaths as $abs) {
175+
if (is_file($abs)) {
176+
$locators[] = new SingleFileSourceLocator($abs, $astLocator);
177+
}
178+
}
179+
180+
// Composer-aware locator: when a project with composer.json + installed.json
181+
// is provided, all parent / interface lookups under PSR-4 / PSR-0 namespaces
182+
// resolve in O(1) via prefix mapping. Without this, parent resolution into
183+
// vendor walks every file (~thousands of file reads + AST parses per FQCN
184+
// miss) — the dominant cost on real-world projects.
185+
if ($this->composerProjectPath !== null && is_dir($this->composerProjectPath)) {
186+
try {
187+
$locators[] = (new MakeLocatorForComposerJsonAndInstalledJson())($this->composerProjectPath, $astLocator);
188+
} catch (MissingComposerJson | MissingInstalledJson) {
189+
// Fall through to source-roots only.
190+
}
191+
}
192+
160193
foreach ($this->sourceRoots as $root) {
161194
if (is_dir($root)) {
162195
$locators[] = new DirectoriesSourceLocator([$root], $astLocator);
163196
}
164197
}
165198
$locators[] = new PhpInternalSourceLocator($astLocator, $stubber);
166199

167-
return new DefaultReflector(new AggregateSourceLocator($locators));
200+
// Memoize by FQCN: any parent / interface looked up more than once
201+
// (which happens for every method / constant / property check on
202+
// a class) reuses the cached result instead of re-walking locators.
203+
return new DefaultReflector(new MemoizingSourceLocator(new AggregateSourceLocator($locators)));
168204
}
169205

170206
/**

tests/DetectTest.php

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ public function testInterfaceImplementationIsNotReportedOnImplementer(): void
139139
], 'implement Iface on Repo');
140140

141141
$body = $this->runDetect();
142-
$this->assertSame('', $body, 'Interface implementations should not be reported as new API surface.');
142+
$this->assertStringNotContainsString('### New API Surface', $body, 'Interface implementations should not be reported as new API surface.');
143+
$this->assertStringNotContainsString('L\\Repo::action', $body);
143144
}
144145

145146
public function testRemovedPublicMethodIsReported(): void
@@ -219,7 +220,7 @@ public function testIncludeInternalFlagShowsInternalAdditions(): void
219220
$this->assertStringContainsString('L\\Foo::impl', $body);
220221
}
221222

222-
public function testModifiedSignatureRequiresFlag(): void
223+
public function testModifiedSignatureFlag(): void
223224
{
224225
$this->commit([
225226
'src/Foo.php' => "<?php\nnamespace L;\nclass Foo {\n public function mut(int \$x): void {}\n}\n",
@@ -228,15 +229,15 @@ public function testModifiedSignatureRequiresFlag(): void
228229
'src/Foo.php' => "<?php\nnamespace L;\nclass Foo {\n public function mut(int \$x, int \$y): void {}\n}\n",
229230
], 'add param');
230231

231-
// Default (show-modified=false) → empty body.
232-
$this->assertSame('', $this->runDetect());
233-
234-
// With the flag → reported under Modified.
235-
$body = $this->runDetect(['SHOW_MODIFIED' => 'true']);
232+
// Default → reported under Modified.
233+
$body = $this->runDetect();
236234
$this->assertStringContainsString('### Modified API Surface', $body);
237235
$this->assertStringContainsString('L\\Foo::mut', $body);
238236
$this->assertStringContainsString('was: `public function mut(int $x): void`', $body);
239237
$this->assertStringContainsString('now: `public function mut(int $x, int $y): void`', $body);
238+
239+
// Explicit opt-out → empty body.
240+
$this->assertSame('', $this->runDetect(['SHOW_MODIFIED' => 'false']));
240241
}
241242

242243
public function testVendorParentMethodIsNotReportedWhenVendorPathIsProvided(): void

0 commit comments

Comments
 (0)