diff --git a/.agents/skills/apm-review-panel/SKILL.md b/.agents/skills/apm-review-panel/SKILL.md index 57ac2dd40..403845415 100644 --- a/.agents/skills/apm-review-panel/SKILL.md +++ b/.agents/skills/apm-review-panel/SKILL.md @@ -195,26 +195,43 @@ Activate when the PR changes any of: - `src/apm_cli/install/phases/**` - `src/apm_cli/install/pipeline.py` - `src/apm_cli/install/resolve.py` +- `src/apm_cli/utils/**` +- `src/apm_cli/marketplace/**` +- `src/apm_cli/compilation/**` - `scripts/perf/**` - `src/apm_cli/core/command_logger.py` (when the diff adds perf-instrumentation logs) -Also activate when the PR description claims a performance win -(speedup ratio, latency reduction, bytes-on-disk reduction, throughput -improvement) or attaches a perf-harness measurement table. +Also activate when: +- The PR description claims a performance win (speedup ratio, latency + reduction, bytes-on-disk reduction, throughput improvement) or + attaches a perf-harness measurement table. +- The diff introduces loops over collections (`for x in collection`) + where the collection may grow with dependency count or file count. +- The diff adds `os.scandir`, `os.walk`, `os.listdir`, or + `subprocess.run` calls on a path that executes per-package or + per-dependency. +- The diff adds `x in list_variable` inside a loop body. Fallback self-check (when no fast-path file matched): "Does this PR change the hot path for dependency download, materialization, cache layout, transport (git protocol, partial clone, sparse checkout), -parallelism, or any user-visible install/update wall-time? If unsure, -answer YES." - -When active, the performance-expert reviews against the package-manager -performance playbook: transport minimization (depth, filter, sparse -scope), cache layering and dedup keys, parallelism and lock contention, -working-tree materialization cost, perf-harness methodology (cache -wipe, warm/cold separation, statistical noise), and pervasive -application of the chosen technique across install / update / run -surfaces (not just the one path the PR exercises). +parallelism, or any user-visible install/update wall-time? Does it +introduce an algorithmic complexity regression (O(n^2) loops, repeated +I/O, missing indexes, unconditional full scans, blocking synchronous +calls, heavy top-level imports)? If unsure, answer YES." + +When active, the performance-expert reviews against BOTH: +1. The package-manager performance playbook: transport minimization + (depth, filter, sparse scope), cache layering and dedup keys, + parallelism and lock contention, working-tree materialization cost, + perf-harness methodology (cache wipe, warm/cold separation, + statistical noise), and pervasive application of the chosen + technique across install / update / run surfaces. +2. The algorithmic performance lens (Big O analysis): complexity class + of every loop/lookup in the diff, index vs linear scan patterns, + unconditional expensive operations, import startup costs, redundant + computation, and parallelism opportunities. See the agent's + `references/algorithmic-patterns.md` for the full pattern catalogue. ### Test Coverage Expert diff --git a/.apm/agents/performance-expert.agent.md b/.apm/agents/performance-expert.agent.md index 423f9cf6a..3233a5156 100644 --- a/.apm/agents/performance-expert.agent.md +++ b/.apm/agents/performance-expert.agent.md @@ -4,9 +4,11 @@ description: >- Performance engineering specialist for package-manager workloads. Activate when reviewing or designing dependency resolution, lockfile schema, cache layout, parallel download phases, git transport, partial clones, - filesystem materialization, or any perf regression in install/update/run - paths in the APM CLI. Encodes the modern best practices for high-throughput - multi-source package managers (git protocol, HTTP archive, content stores) + filesystem materialization, or any code path that introduces algorithmic + complexity regressions (O(n^2) loops, repeated I/O, missing indexes, + unconditional full scans, blocking synchronous calls on hot paths, heavy + top-level imports) in the APM CLI. Encodes Big O analysis, the modern + package-manager performance playbook, and caching/indexing best practices applied to APM's git-first dependency model. model: claude-opus-4.6 --- @@ -195,5 +197,64 @@ the hot path runs. For APM specifically: `cli-logging-expert`). - Not a release-decision maker (that's `apm-ceo`). -Stay in your lane: measurable wall-time, bytes, round-trips, and -follow-up issues that move the needle. +Stay in your lane: measurable wall-time, bytes, round-trips, +algorithmic complexity, and follow-up issues that move the needle. + +## Algorithmic performance lens + +When the PR touches code OUTSIDE the transport/cache layer, load +`references/algorithmic-patterns.md` and apply the algorithmic +analysis lens. This covers: + +### Big O analysis + +For every loop or collection operation in the diff, state its +complexity class. Flag any path that is O(n^2) or worse when an +O(n) or O(1) alternative exists. Common patterns to catch: + +- `x in list` inside a loop -> recommend set/dict index +- Nested iteration over the same collection -> recommend single-pass + with auxiliary dict +- Sorting inside a loop -> recommend sorting once outside +- Linear scan for identity/key match -> recommend pre-built index +- `any(pred(x) for x in coll)` called per-item -> recommend set + +### Unconditional expensive operations + +Flag methods that perform costly work (directory scans, full +re-serialisation, network calls) on every invocation when a fast-path +skip would avoid the cost in the common case. The pattern: + +1. Track a cheap signal (running total, dirty flag, generation counter) +2. Only perform the expensive operation when the signal crosses a + threshold +3. Update the signal on every mutation + +### Import and startup costs + +Flag top-level imports that pull in heavy transitive module graphs +when the importing module's primary code path does not need them. +The fix: move the import to the function scope where it is actually +used. This matters for CLI commands -- only one command runs per +invocation but all top-level imports execute at startup. + +### Redundant computation + +Flag repeated parsing/normalisation of the same data (environment +variables parsed identically in multiple functions, config files +re-read on every call, metadata JSON parsed twice in sequence). +The fix: extract to a shared helper or cache the result. + +### Parallelism opportunities + +Flag sequential I/O loops where iterations are independent (no data +dependency between loop bodies). The fix: `ThreadPoolExecutor` with +bounded concurrency for I/O-bound work, or `ProcessPoolExecutor` for +CPU-bound work. + +### Scaling guard recommendations + +For every performance fix you recommend, also recommend a +scaling-guard test: run the operation at N and 10*N, assert the +ratio stays below a threshold. This catches future regressions +without brittle absolute-time assertions. diff --git a/.apm/agents/references/algorithmic-patterns.md b/.apm/agents/references/algorithmic-patterns.md new file mode 100644 index 000000000..bd4e4afac --- /dev/null +++ b/.apm/agents/references/algorithmic-patterns.md @@ -0,0 +1,152 @@ +# Algorithmic Performance Patterns + +Load this reference when the PR diff touches code outside the +transport/cache layer -- i.e. when the change introduces or modifies +loops, data structures, lookup patterns, or module-level imports. + +## Big O Quick Reference + +| Pattern | Complexity | Red Flag | +|---------|-----------|----------| +| Dict/set lookup | O(1) | Fine | +| List `.append` | O(1) amortised | Fine | +| `x in list` | O(n) | Use a set if called in a loop | +| Nested loops over same collection | O(n^2) | Extract an index dict first | +| Sort inside a loop | O(n^2 log n) | Sort once outside the loop | +| `any(pred(x) for x in coll)` in a loop | O(n*m) | Build a set/dict pre-loop | +| Unconditional full-dir scan on every write | O(n) per write = O(n^2) total | Track running total; scan only when needed | +| Linear search for identity match | O(n) per lookup | Build `{identity: index}` once | + +## Anti-Patterns to Flag + +### 1. Missing Index on Repeated Lookup + +```python +# BAD: O(n) per call, called m times = O(n*m) +def has_item(collection, key): + return any(item.key == key for item in collection) + +# GOOD: O(1) per call after O(n) setup +_index = {item.key for item in collection} +def has_item(key): + return key in _index +``` + +Flag when: a function does linear scan AND is called from within a +loop or from a method called repeatedly during resolution/install. + +### 2. Unconditional Expensive Operation + +```python +# BAD: scans entire cache dir on every store() +def store(self, url, body): + self._write(url, body) + self._enforce_size_cap() # full scandir every time + +# GOOD: fast-path skip when clearly under budget +def store(self, url, body): + self._write(url, body) + self._tracked_size += len(body) + if self._tracked_size > MAX_SIZE: + self._enforce_size_cap() # scan only when needed +``` + +Flag when: an expensive operation (directory walk, sort, full +re-computation) runs unconditionally on every call to a high-frequency +method. + +### 3. Triple-Pass Where Single-Pass Suffices + +```python +# BAD: iterates refs 3x (once per category) +for ref in refs: + if ref.startswith("refs/tags/"): ... +for ref in refs: + if ref.name == target: ... +for ref in refs: + if ref.name == f"refs/heads/{target}": ... + +# GOOD: single pass builds lookup dicts +tags, branches, by_name = {}, {}, {} +for ref in refs: + by_name[ref.name] = ref + if ref.name.startswith("refs/tags/"): + tags[strip_prefix(ref.name)] = ref + elif ref.name.startswith("refs/heads/"): + branches[ref.name[len("refs/heads/"):]] = ref +# Then O(1) lookups +``` + +Flag when: the same collection is iterated multiple times with +different predicates that could all be evaluated in one pass. + +### 4. Repeated Environment/Config Parsing + +```python +# BAD: re-parses on every call +def classify_host(hostname): + ghes = os.environ.get("GITHUB_HOST", "").strip().lower().split("/")[0] + # ... repeated in 5 other functions + +# GOOD: parse once in a helper +def _get_ghes_host(): + return os.environ.get("GITHUB_HOST", "").strip().lower().split("/")[0] +``` + +Flag when: the same `os.environ.get()` + normalisation chain appears +in multiple functions that are called in tight succession. + +### 5. Heavy Top-Level Imports on CLI Startup + +```python +# BAD: imports entire install engine at module level +from apm_cli.install.pipeline import FullPipeline # 40+ transitive modules + +# GOOD: defer to function scope +def install_command(): + from apm_cli.install.pipeline import FullPipeline + ... +``` + +Flag when: a command module imports heavy subpackages at the top level +that are not needed for other commands sharing the same CLI entrypoint. + +### 6. Synchronous Blocking in Parallelisable Paths + +```python +# BAD: sequential I/O in a loop +for pkg in packages: + metadata = fetch_metadata(pkg) # blocking network call + +# GOOD: parallel with bounded concurrency +with ThreadPoolExecutor(max_workers=8) as pool: + metadata_list = list(pool.map(fetch_metadata, packages)) +``` + +Flag when: a loop performs independent I/O operations (file reads, +network calls, subprocess spawns) that have no data dependency between +iterations. + +## Scaling Guard Pattern + +When reviewing a fix, recommend a scaling-guard test: +- Run the operation at size N and at size 10*N +- Assert the time ratio stays below a threshold (e.g. < 15x) +- This catches O(n^2) regressions without brittle absolute-time assertions + +```python +def test_scaling_ratio(): + t_small = median_time(lambda: operation(n=50)) + t_large = median_time(lambda: operation(n=500)) + ratio = t_large / t_small + assert ratio < 15, f"Ratio {ratio:.1f}x suggests O(n^2)" +``` + +## Quantification Checklist + +When reporting a performance finding, always state: +1. **What** -- the specific pattern (name it from the table above) +2. **Where** -- file:line range +3. **Frequency** -- how often this code path executes per typical run +4. **Complexity** -- the current Big O and the proposed Big O +5. **Fix** -- concrete code sketch (not just "optimise this") diff --git a/.github/agents/algorithmic-patterns.agent.md b/.github/agents/algorithmic-patterns.agent.md new file mode 100644 index 000000000..bd4e4afac --- /dev/null +++ b/.github/agents/algorithmic-patterns.agent.md @@ -0,0 +1,152 @@ +# Algorithmic Performance Patterns + +Load this reference when the PR diff touches code outside the +transport/cache layer -- i.e. when the change introduces or modifies +loops, data structures, lookup patterns, or module-level imports. + +## Big O Quick Reference + +| Pattern | Complexity | Red Flag | +|---------|-----------|----------| +| Dict/set lookup | O(1) | Fine | +| List `.append` | O(1) amortised | Fine | +| `x in list` | O(n) | Use a set if called in a loop | +| Nested loops over same collection | O(n^2) | Extract an index dict first | +| Sort inside a loop | O(n^2 log n) | Sort once outside the loop | +| `any(pred(x) for x in coll)` in a loop | O(n*m) | Build a set/dict pre-loop | +| Unconditional full-dir scan on every write | O(n) per write = O(n^2) total | Track running total; scan only when needed | +| Linear search for identity match | O(n) per lookup | Build `{identity: index}` once | + +## Anti-Patterns to Flag + +### 1. Missing Index on Repeated Lookup + +```python +# BAD: O(n) per call, called m times = O(n*m) +def has_item(collection, key): + return any(item.key == key for item in collection) + +# GOOD: O(1) per call after O(n) setup +_index = {item.key for item in collection} +def has_item(key): + return key in _index +``` + +Flag when: a function does linear scan AND is called from within a +loop or from a method called repeatedly during resolution/install. + +### 2. Unconditional Expensive Operation + +```python +# BAD: scans entire cache dir on every store() +def store(self, url, body): + self._write(url, body) + self._enforce_size_cap() # full scandir every time + +# GOOD: fast-path skip when clearly under budget +def store(self, url, body): + self._write(url, body) + self._tracked_size += len(body) + if self._tracked_size > MAX_SIZE: + self._enforce_size_cap() # scan only when needed +``` + +Flag when: an expensive operation (directory walk, sort, full +re-computation) runs unconditionally on every call to a high-frequency +method. + +### 3. Triple-Pass Where Single-Pass Suffices + +```python +# BAD: iterates refs 3x (once per category) +for ref in refs: + if ref.startswith("refs/tags/"): ... +for ref in refs: + if ref.name == target: ... +for ref in refs: + if ref.name == f"refs/heads/{target}": ... + +# GOOD: single pass builds lookup dicts +tags, branches, by_name = {}, {}, {} +for ref in refs: + by_name[ref.name] = ref + if ref.name.startswith("refs/tags/"): + tags[strip_prefix(ref.name)] = ref + elif ref.name.startswith("refs/heads/"): + branches[ref.name[len("refs/heads/"):]] = ref +# Then O(1) lookups +``` + +Flag when: the same collection is iterated multiple times with +different predicates that could all be evaluated in one pass. + +### 4. Repeated Environment/Config Parsing + +```python +# BAD: re-parses on every call +def classify_host(hostname): + ghes = os.environ.get("GITHUB_HOST", "").strip().lower().split("/")[0] + # ... repeated in 5 other functions + +# GOOD: parse once in a helper +def _get_ghes_host(): + return os.environ.get("GITHUB_HOST", "").strip().lower().split("/")[0] +``` + +Flag when: the same `os.environ.get()` + normalisation chain appears +in multiple functions that are called in tight succession. + +### 5. Heavy Top-Level Imports on CLI Startup + +```python +# BAD: imports entire install engine at module level +from apm_cli.install.pipeline import FullPipeline # 40+ transitive modules + +# GOOD: defer to function scope +def install_command(): + from apm_cli.install.pipeline import FullPipeline + ... +``` + +Flag when: a command module imports heavy subpackages at the top level +that are not needed for other commands sharing the same CLI entrypoint. + +### 6. Synchronous Blocking in Parallelisable Paths + +```python +# BAD: sequential I/O in a loop +for pkg in packages: + metadata = fetch_metadata(pkg) # blocking network call + +# GOOD: parallel with bounded concurrency +with ThreadPoolExecutor(max_workers=8) as pool: + metadata_list = list(pool.map(fetch_metadata, packages)) +``` + +Flag when: a loop performs independent I/O operations (file reads, +network calls, subprocess spawns) that have no data dependency between +iterations. + +## Scaling Guard Pattern + +When reviewing a fix, recommend a scaling-guard test: +- Run the operation at size N and at size 10*N +- Assert the time ratio stays below a threshold (e.g. < 15x) +- This catches O(n^2) regressions without brittle absolute-time assertions + +```python +def test_scaling_ratio(): + t_small = median_time(lambda: operation(n=50)) + t_large = median_time(lambda: operation(n=500)) + ratio = t_large / t_small + assert ratio < 15, f"Ratio {ratio:.1f}x suggests O(n^2)" +``` + +## Quantification Checklist + +When reporting a performance finding, always state: +1. **What** -- the specific pattern (name it from the table above) +2. **Where** -- file:line range +3. **Frequency** -- how often this code path executes per typical run +4. **Complexity** -- the current Big O and the proposed Big O +5. **Fix** -- concrete code sketch (not just "optimise this") diff --git a/.github/agents/performance-expert.agent.md b/.github/agents/performance-expert.agent.md index 423f9cf6a..3233a5156 100644 --- a/.github/agents/performance-expert.agent.md +++ b/.github/agents/performance-expert.agent.md @@ -4,9 +4,11 @@ description: >- Performance engineering specialist for package-manager workloads. Activate when reviewing or designing dependency resolution, lockfile schema, cache layout, parallel download phases, git transport, partial clones, - filesystem materialization, or any perf regression in install/update/run - paths in the APM CLI. Encodes the modern best practices for high-throughput - multi-source package managers (git protocol, HTTP archive, content stores) + filesystem materialization, or any code path that introduces algorithmic + complexity regressions (O(n^2) loops, repeated I/O, missing indexes, + unconditional full scans, blocking synchronous calls on hot paths, heavy + top-level imports) in the APM CLI. Encodes Big O analysis, the modern + package-manager performance playbook, and caching/indexing best practices applied to APM's git-first dependency model. model: claude-opus-4.6 --- @@ -195,5 +197,64 @@ the hot path runs. For APM specifically: `cli-logging-expert`). - Not a release-decision maker (that's `apm-ceo`). -Stay in your lane: measurable wall-time, bytes, round-trips, and -follow-up issues that move the needle. +Stay in your lane: measurable wall-time, bytes, round-trips, +algorithmic complexity, and follow-up issues that move the needle. + +## Algorithmic performance lens + +When the PR touches code OUTSIDE the transport/cache layer, load +`references/algorithmic-patterns.md` and apply the algorithmic +analysis lens. This covers: + +### Big O analysis + +For every loop or collection operation in the diff, state its +complexity class. Flag any path that is O(n^2) or worse when an +O(n) or O(1) alternative exists. Common patterns to catch: + +- `x in list` inside a loop -> recommend set/dict index +- Nested iteration over the same collection -> recommend single-pass + with auxiliary dict +- Sorting inside a loop -> recommend sorting once outside +- Linear scan for identity/key match -> recommend pre-built index +- `any(pred(x) for x in coll)` called per-item -> recommend set + +### Unconditional expensive operations + +Flag methods that perform costly work (directory scans, full +re-serialisation, network calls) on every invocation when a fast-path +skip would avoid the cost in the common case. The pattern: + +1. Track a cheap signal (running total, dirty flag, generation counter) +2. Only perform the expensive operation when the signal crosses a + threshold +3. Update the signal on every mutation + +### Import and startup costs + +Flag top-level imports that pull in heavy transitive module graphs +when the importing module's primary code path does not need them. +The fix: move the import to the function scope where it is actually +used. This matters for CLI commands -- only one command runs per +invocation but all top-level imports execute at startup. + +### Redundant computation + +Flag repeated parsing/normalisation of the same data (environment +variables parsed identically in multiple functions, config files +re-read on every call, metadata JSON parsed twice in sequence). +The fix: extract to a shared helper or cache the result. + +### Parallelism opportunities + +Flag sequential I/O loops where iterations are independent (no data +dependency between loop bodies). The fix: `ThreadPoolExecutor` with +bounded concurrency for I/O-bound work, or `ProcessPoolExecutor` for +CPU-bound work. + +### Scaling guard recommendations + +For every performance fix you recommend, also recommend a +scaling-guard test: run the operation at N and 10*N, assert the +ratio stays below a threshold. This catches future regressions +without brittle absolute-time assertions. diff --git a/apm.lock.yaml b/apm.lock.yaml index 2e3844c89..ea3539e5c 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -1,5 +1,5 @@ lockfile_version: '1' -generated_at: '2026-07-11T22:04:42.215028+00:00' +generated_at: '2026-07-12T05:33:40.045023+00:00' apm_version: 0.24.1 dependencies: - repo_url: _local/apm-issue-autopilot @@ -253,7 +253,7 @@ dependencies: - .agents/skills/apm-review-panel/evals/render_eval.py - .agents/skills/apm-review-panel/evals/trigger-evals.json deployed_file_hashes: - .agents/skills/apm-review-panel/SKILL.md: sha256:fc63e25479c5c2066f317b5b0e430738bd8a67054a2ae6934fbe5b3d281fa10a + .agents/skills/apm-review-panel/SKILL.md: sha256:c9fee6c311b23b6ea72b06a424c8ba21c5d88bcc833e65259b7e0212aa73abbb .agents/skills/apm-review-panel/apm.yml: sha256:80c403c4d3c59a91bb27b85650e21a466e9cd0cbc28e92bf0f3e53c6ea71cb2c .agents/skills/apm-review-panel/assets/ceo-return-schema.json: sha256:d8707211968efb0471d083f880d5353d66a0eda84635e803a930f60c91837468 .agents/skills/apm-review-panel/assets/panelist-return-schema.json: sha256:e3cf2ae17e93dd934f2659ed77d2640ea9601fbe8698f63ba800edf046691f99 @@ -549,7 +549,7 @@ deployments: owners: - local:packages/apm-review-panel active_owner: local:packages/apm-review-panel - content_hash: sha256:fc63e25479c5c2066f317b5b0e430738bd8a67054a2ae6934fbe5b3d281fa10a + content_hash: sha256:c9fee6c311b23b6ea72b06a424c8ba21c5d88bcc833e65259b7e0212aa73abbb - kind: project-relative target: agents value: .agents/skills/apm-review-panel/apm.yml @@ -2584,6 +2584,15 @@ deployments: - . active_owner: . content_hash: sha256:d1ea2d038e2af8be11d6c95b3213b03b9777fae46f0438efa95d5a803e6c3765 +- kind: project-relative + target: copilot + value: .github/agents/algorithmic-patterns.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:278321484288eb8965a42c20cfa65ff055d250579849b1c48cbd3f553883fe5d - kind: project-relative target: copilot value: .github/agents/apm-ceo.agent.md @@ -2682,7 +2691,7 @@ deployments: owners: - . active_owner: . - content_hash: sha256:5262b505660c3e8ec2064cbd2f9e2732e6cae2d65b0292abe99b90b077c26ef4 + content_hash: sha256:e2cbfd17dddc36fb732f69e47f683a4754cbaac9ac5d911899da34d597e1be62 - kind: project-relative target: copilot value: .github/agents/python-architect.agent.md @@ -2746,6 +2755,15 @@ deployments: - . active_owner: . content_hash: sha256:8fb8cc426d6af17ba084a28b3f026c2b475b62e3ca63ed2f88b83bd823f877af +- kind: project-relative + target: copilot + value: .github/agents/test-coverage-expert.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:bc588d89530362469502bfbea788df892a9a0b00e630cd0f3926d3dfd2c2a9e2 - kind: project-relative target: copilot value: .github/instructions/architecture.instructions.md @@ -2991,6 +3009,7 @@ local_deployed_files: - .agents/skills/supply-chain-security - .agents/skills/supply-chain-security/SKILL.md - .github/agents/agentic-workflows.agent.md +- .github/agents/algorithmic-patterns.agent.md - .github/agents/apm-ceo.agent.md - .github/agents/apm-primitives-architect.agent.md - .github/agents/auth-expert.agent.md @@ -3009,6 +3028,7 @@ local_deployed_files: - .github/agents/spec-swagger-editor.agent.md - .github/agents/spec-tag-architect.agent.md - .github/agents/supply-chain-security-expert.agent.md +- .github/agents/test-coverage-expert.agent.md - .github/instructions/architecture.instructions.md - .github/instructions/changelog.instructions.md - .github/instructions/cicd.instructions.md @@ -3239,6 +3259,7 @@ local_deployed_file_hashes: .agents/skills/python-architecture/SKILL.md: sha256:f06e50b8c40d7e5a732a5405a603efd80da58de2b6f6b293f0f6b58f28eefe7b .agents/skills/supply-chain-security/SKILL.md: sha256:55ef10cd0f6b68e0db5a435a79851c055eab74d93fd552c6626df631deb51df4 .github/agents/agentic-workflows.agent.md: sha256:d1ea2d038e2af8be11d6c95b3213b03b9777fae46f0438efa95d5a803e6c3765 + .github/agents/algorithmic-patterns.agent.md: sha256:278321484288eb8965a42c20cfa65ff055d250579849b1c48cbd3f553883fe5d .github/agents/apm-ceo.agent.md: sha256:484da64428ea46a6183dffd3f30c9fc5fc5c747639c0c79e55be69dba0899323 .github/agents/apm-primitives-architect.agent.md: sha256:0c8f04297b14e144f84211b5b881b099233a9847c0fca257b9aa69c4d43d1eef .github/agents/auth-expert.agent.md: sha256:18264a933cba432b77d133e6ae11eee294c92ed245629af8c9b7a5bb7a9a300c @@ -3249,7 +3270,7 @@ local_deployed_file_hashes: .github/agents/doc-writer.agent.md: sha256:328a5b9ea079869b8ccd914a6e2135c204225a5eedb42f59a1ec73058f7f0b47 .github/agents/editorial-owner.agent.md: sha256:9dd101a9476dd93b67da1b823cc3b649f1227168fd809b108c74f9304262d860 .github/agents/oss-growth-hacker.agent.md: sha256:1cd56bb78ab37d52c50e45ab69d759f775cd49cdf35981b3dc6c4004315c6b83 - .github/agents/performance-expert.agent.md: sha256:5262b505660c3e8ec2064cbd2f9e2732e6cae2d65b0292abe99b90b077c26ef4 + .github/agents/performance-expert.agent.md: sha256:e2cbfd17dddc36fb732f69e47f683a4754cbaac9ac5d911899da34d597e1be62 .github/agents/python-architect.agent.md: sha256:9b18ed8f318755055e304edaf283435c446c1404a2199e53d2558acfb076fca2 .github/agents/spec-editor-synthesizer.agent.md: sha256:dad51758c91753528f3a80e1102f5a71460b816897d650c0f0ad4eeb7b1c8928 .github/agents/spec-oci-editor.agent.md: sha256:d96482cb733b0540b14ba0812fde48c07d8937ef11507b81c28a239b35b4f94a @@ -3257,6 +3278,7 @@ local_deployed_file_hashes: .github/agents/spec-swagger-editor.agent.md: sha256:51019f52b2a2aed2a269187b58e6a867802636a79d1410153dc9b5b28d5d1b25 .github/agents/spec-tag-architect.agent.md: sha256:82907265c5e7cf1ac61ad96866fa7c5683b69c8f09b7a4c5f3cc241acc9568ca .github/agents/supply-chain-security-expert.agent.md: sha256:8fb8cc426d6af17ba084a28b3f026c2b475b62e3ca63ed2f88b83bd823f877af + .github/agents/test-coverage-expert.agent.md: sha256:bc588d89530362469502bfbea788df892a9a0b00e630cd0f3926d3dfd2c2a9e2 .github/instructions/architecture.instructions.md: sha256:a24c5c8ce4f50855a10e4b6c0814ab8e6faa4bdb0aaa715f6cf7a5fe6f253121 .github/instructions/changelog.instructions.md: sha256:1e51ec4c74e847967962bd279dc4c6e582c5d3578490b3c28d5f3acd3e05f73e .github/instructions/cicd.instructions.md: sha256:08d87b7d635761cb41deb8fc71d5d83f54678de463db484afb16d2d4f8713ecb diff --git a/packages/apm-review-panel/SKILL.md b/packages/apm-review-panel/SKILL.md index 57ac2dd40..403845415 100644 --- a/packages/apm-review-panel/SKILL.md +++ b/packages/apm-review-panel/SKILL.md @@ -195,26 +195,43 @@ Activate when the PR changes any of: - `src/apm_cli/install/phases/**` - `src/apm_cli/install/pipeline.py` - `src/apm_cli/install/resolve.py` +- `src/apm_cli/utils/**` +- `src/apm_cli/marketplace/**` +- `src/apm_cli/compilation/**` - `scripts/perf/**` - `src/apm_cli/core/command_logger.py` (when the diff adds perf-instrumentation logs) -Also activate when the PR description claims a performance win -(speedup ratio, latency reduction, bytes-on-disk reduction, throughput -improvement) or attaches a perf-harness measurement table. +Also activate when: +- The PR description claims a performance win (speedup ratio, latency + reduction, bytes-on-disk reduction, throughput improvement) or + attaches a perf-harness measurement table. +- The diff introduces loops over collections (`for x in collection`) + where the collection may grow with dependency count or file count. +- The diff adds `os.scandir`, `os.walk`, `os.listdir`, or + `subprocess.run` calls on a path that executes per-package or + per-dependency. +- The diff adds `x in list_variable` inside a loop body. Fallback self-check (when no fast-path file matched): "Does this PR change the hot path for dependency download, materialization, cache layout, transport (git protocol, partial clone, sparse checkout), -parallelism, or any user-visible install/update wall-time? If unsure, -answer YES." - -When active, the performance-expert reviews against the package-manager -performance playbook: transport minimization (depth, filter, sparse -scope), cache layering and dedup keys, parallelism and lock contention, -working-tree materialization cost, perf-harness methodology (cache -wipe, warm/cold separation, statistical noise), and pervasive -application of the chosen technique across install / update / run -surfaces (not just the one path the PR exercises). +parallelism, or any user-visible install/update wall-time? Does it +introduce an algorithmic complexity regression (O(n^2) loops, repeated +I/O, missing indexes, unconditional full scans, blocking synchronous +calls, heavy top-level imports)? If unsure, answer YES." + +When active, the performance-expert reviews against BOTH: +1. The package-manager performance playbook: transport minimization + (depth, filter, sparse scope), cache layering and dedup keys, + parallelism and lock contention, working-tree materialization cost, + perf-harness methodology (cache wipe, warm/cold separation, + statistical noise), and pervasive application of the chosen + technique across install / update / run surfaces. +2. The algorithmic performance lens (Big O analysis): complexity class + of every loop/lookup in the diff, index vs linear scan patterns, + unconditional expensive operations, import startup costs, redundant + computation, and parallelism opportunities. See the agent's + `references/algorithmic-patterns.md` for the full pattern catalogue. ### Test Coverage Expert diff --git a/src/apm_cli/cache/http_cache.py b/src/apm_cli/cache/http_cache.py index 65703f922..dad89cc55 100644 --- a/src/apm_cli/cache/http_cache.py +++ b/src/apm_cli/cache/http_cache.py @@ -70,6 +70,7 @@ def __init__(self, cache_root: Path) -> None: self._cache_dir.mkdir(parents=True, exist_ok=True) os.chmod(str(self._cache_dir), 0o700) cleanup_incomplete(self._cache_dir) + self._tracked_size: int | None = None def get(self, url: str, headers: dict[str, str] | None = None) -> CacheEntry | None: """Look up a cached response for *url*. @@ -233,6 +234,13 @@ def store( with contextlib.suppress(OSError): os.utime(str(entry_path), None) + # Update tracked size with an upper-bound estimate. Over-counting is + # intentional: it triggers a real scan sooner, correcting the estimate, + # rather than delaying eviction. The scan in _enforce_size_cap resets + # _tracked_size to the real total once it runs. + if self._tracked_size is not None: + self._tracked_size += len(body) + 512 # body + metadata upper-bound estimate + # Enforce size cap self._enforce_size_cap() @@ -321,10 +329,20 @@ def _parse_ttl(self, headers: dict[str, str]) -> float: return 300.0 def _enforce_size_cap(self) -> None: - """Evict LRU entries if total cache size exceeds the cap.""" + """Evict LRU entries if total cache size exceeds the cap. + + Uses a tracked size estimate to skip the full directory scan + when we are clearly under the cap (fast path). Falls back to a + full scan when the tracked size exceeds the limit or has not + been computed yet. + """ if not self._cache_dir.is_dir(): return + # Fast path: if we have a tracked size and it is under cap, skip scan + if self._tracked_size is not None and self._tracked_size <= MAX_HTTP_CACHE_BYTES: + return + entries: list[tuple[float, str, int]] = [] total_size = 0 @@ -343,6 +361,8 @@ def _enforce_size_cap(self) -> None: except OSError: continue + self._tracked_size = total_size + if total_size <= MAX_HTTP_CACHE_BYTES: return @@ -356,3 +376,5 @@ def _enforce_size_cap(self) -> None: break robust_rmtree(Path(path), ignore_errors=True) total_size -= size + + self._tracked_size = total_size diff --git a/src/apm_cli/deps/dependency_graph.py b/src/apm_cli/deps/dependency_graph.py index 3e5333009..54d448e40 100644 --- a/src/apm_cli/deps/dependency_graph.py +++ b/src/apm_cli/deps/dependency_graph.py @@ -81,6 +81,7 @@ class DependencyTree: default_factory=lambda: defaultdict(list) ) _nodes_by_unique_key: dict[str, DependencyNode] = field(default_factory=dict) + _repo_url_index: set[str] = field(default_factory=set) max_depth: int = 0 resolution_errors: list[str] = field(default_factory=list) @@ -95,6 +96,8 @@ def add_node(self, node: DependencyNode) -> None: self._nodes_by_depth[node.depth].append(node) else: self._nodes_by_unique_key[unique_key] = node + if node.dependency_ref.repo_url: + self._repo_url_index.add(node.dependency_ref.repo_url) self.max_depth = max(self.max_depth, node.depth) def get_node(self, unique_key: str) -> DependencyNode | None: @@ -112,9 +115,8 @@ def get_nodes_at_depth(self, depth: int) -> list[DependencyNode]: return list(self._nodes_by_depth.get(depth, [])) def has_dependency(self, repo_url: str) -> bool: - """Check if a dependency exists in the tree.""" - # Check by repo URL, not by full node ID (which may include reference) - return any(node.dependency_ref.repo_url == repo_url for node in self.nodes.values()) + """Check if a dependency exists in the tree (O(1) via index).""" + return repo_url in self._repo_url_index @dataclass diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py index 62531bceb..aaa738b62 100644 --- a/src/apm_cli/marketplace/builder.py +++ b/src/apm_cli/marketplace/builder.py @@ -653,64 +653,75 @@ def _resolve_explicit_ref( refs = resolver.list_remote_refs(owner_repo) - # Try as tag first (only check tag refs) + # Single-pass index for O(1) lookup by tag name, full refname, and branch + tags_by_name: dict[str, Any] = {} + refs_by_name: dict[str, Any] = {} + branches_by_name: dict[str, Any] = {} for remote_ref in refs: - if not remote_ref.name.startswith("refs/tags/"): - continue + refs_by_name[remote_ref.name] = remote_ref + if remote_ref.name.startswith("refs/tags/"): + tag_name = _strip_ref_prefix(remote_ref.name) + tags_by_name[tag_name] = remote_ref + elif remote_ref.name.startswith("refs/heads/"): + branch_name = remote_ref.name[len("refs/heads/") :] + branches_by_name[branch_name] = remote_ref + + # Try as tag first + if ref_text in tags_by_name: + remote_ref = tags_by_name[ref_text] tag_name = _strip_ref_prefix(remote_ref.name) - if tag_name == ref_text: - sv = parse_semver(tag_name.lstrip("vV")) - return ResolvedPackage( - name=entry.name, - source_repo=owner_repo, - subdir=entry.subdir, - ref=tag_name, - sha=remote_ref.sha, - requested_version=entry.version, - tags=entry.tags, - is_prerelease=sv.is_prerelease if sv else False, - host=self._resolved_output_host(source_host=source_host, source_url=source_url), - source_url=source_url, - ) + sv = parse_semver(tag_name.lstrip("vV")) + return ResolvedPackage( + name=entry.name, + source_repo=owner_repo, + subdir=entry.subdir, + ref=tag_name, + sha=remote_ref.sha, + requested_version=entry.version, + tags=entry.tags, + is_prerelease=sv.is_prerelease if sv else False, + host=self._resolved_output_host(source_host=source_host, source_url=source_url), + source_url=source_url, + ) # Try as full refname - for remote_ref in refs: - if remote_ref.name == ref_text: - short = _strip_ref_prefix(remote_ref.name) - is_branch = remote_ref.name.startswith("refs/heads/") - if is_branch and not self._options.allow_head: - raise HeadNotAllowedError(entry.name, short) - sv = parse_semver(short.lstrip("vV")) - return ResolvedPackage( - name=entry.name, - source_repo=owner_repo, - subdir=entry.subdir, - ref=short, - sha=remote_ref.sha, - requested_version=entry.version, - tags=entry.tags, - is_prerelease=sv.is_prerelease if sv else False, - host=self._resolved_output_host(source_host=source_host, source_url=source_url), - source_url=source_url, - ) + if ref_text in refs_by_name: + remote_ref = refs_by_name[ref_text] + short = _strip_ref_prefix(remote_ref.name) + is_branch = remote_ref.name.startswith("refs/heads/") + if is_branch and not self._options.allow_head: + raise HeadNotAllowedError(entry.name, short) + sv = parse_semver(short.lstrip("vV")) + return ResolvedPackage( + name=entry.name, + source_repo=owner_repo, + subdir=entry.subdir, + ref=short, + sha=remote_ref.sha, + requested_version=entry.version, + tags=entry.tags, + is_prerelease=sv.is_prerelease if sv else False, + host=self._resolved_output_host(source_host=source_host, source_url=source_url), + source_url=source_url, + ) # Try as branch name - for remote_ref in refs: - if remote_ref.name == f"refs/heads/{ref_text}": - if not self._options.allow_head: - raise HeadNotAllowedError(entry.name, ref_text) - return ResolvedPackage( - name=entry.name, - source_repo=owner_repo, - subdir=entry.subdir, - ref=ref_text, - sha=remote_ref.sha, - requested_version=entry.version, - tags=entry.tags, - is_prerelease=False, - host=self._resolved_output_host(source_host=source_host, source_url=source_url), - source_url=source_url, - ) + if ref_text in branches_by_name: + remote_ref = branches_by_name[ref_text] + if not self._options.allow_head: + raise HeadNotAllowedError(entry.name, ref_text) + return ResolvedPackage( + name=entry.name, + source_repo=owner_repo, + subdir=entry.subdir, + ref=ref_text, + sha=remote_ref.sha, + requested_version=entry.version, + tags=entry.tags, + is_prerelease=False, + host=self._resolved_output_host(source_host=source_host, source_url=source_url), + source_url=source_url, + ) # HEAD special case if ref_text.upper() == "HEAD": diff --git a/src/apm_cli/utils/github_host.py b/src/apm_cli/utils/github_host.py index da7d654cb..560a73812 100644 --- a/src/apm_cli/utils/github_host.py +++ b/src/apm_cli/utils/github_host.py @@ -5,6 +5,22 @@ import urllib.parse +def _get_ghes_host() -> str: + """Return the normalised GITHUB_HOST env value.""" + return os.environ.get("GITHUB_HOST", "").strip().lower().split("/")[0] + + +def _get_gitlab_single_host() -> str: + """Return the normalised GITLAB_HOST env value.""" + return os.environ.get("GITLAB_HOST", "").strip().lower().split("/")[0] + + +def _get_gitlab_hosts_list() -> list[str]: + """Return normalised APM_GITLAB_HOSTS entries.""" + raw = os.environ.get("APM_GITLAB_HOSTS", "") + return [part.strip().lower().split("/")[0] for part in raw.split(",") if part.strip()] + + def default_host() -> str: """Return the default Git host (can be overridden via GITHUB_HOST env var).""" return os.environ.get("GITHUB_HOST", "github.com") @@ -47,8 +63,8 @@ def is_gitlab_hostname(hostname: str | None) -> bool: Matches, in order of what this function checks (not full ``classify_host`` order): - ``gitlab.com`` (case-insensitive) - - ``GITLAB_HOST`` — single self-managed host (same pattern as ``GITHUB_HOST`` for GHES) - - ``APM_GITLAB_HOSTS`` — comma-separated list of self-managed hosts + - ``GITLAB_HOST`` -- single self-managed host (same pattern as ``GITHUB_HOST`` for GHES) + - ``APM_GITLAB_HOSTS`` -- comma-separated list of self-managed hosts **GHES precedence:** If ``GITHUB_HOST`` matches *hostname* under the same rules as ``AuthResolver.classify_host`` (GHES, not ``gitlab.com`` SaaS), @@ -62,7 +78,7 @@ def is_gitlab_hostname(hostname: str | None) -> bool: # GHES precedence: GITHUB_HOST match is enterprise GitHub, not GitLab, even if # the same host appears in GitLab env vars (GHES takes priority over any # GitLab environment hint). - ghes_host = os.environ.get("GITHUB_HOST", "").strip().lower().split("/")[0] + ghes_host = _get_ghes_host() if ( ghes_host and ghes_host == h @@ -74,15 +90,10 @@ def is_gitlab_hostname(hostname: str | None) -> bool: if h == "gitlab.com": return True - gitlab_single = os.environ.get("GITLAB_HOST", "").strip().lower().split("/")[0] + gitlab_single = _get_gitlab_single_host() if gitlab_single and gitlab_single == h: return is_valid_fqdn(h) - raw_list = os.environ.get("APM_GITLAB_HOSTS", "") - for part in raw_list.split(","): - entry = part.strip().lower().split("/")[0] - if entry and entry == h and is_valid_fqdn(entry): - return True - return False + return any(entry and entry == h and is_valid_fqdn(entry) for entry in _get_gitlab_hosts_list()) def has_github_gitlab_host_env_conflict(hostname: str | None) -> bool: @@ -101,7 +112,7 @@ def has_github_gitlab_host_env_conflict(hostname: str | None) -> bool: if not is_valid_fqdn(h): return False - ghes_host = os.environ.get("GITHUB_HOST", "").strip().lower().split("/")[0] + ghes_host = _get_ghes_host() github_claims_as_ghes = ( ghes_host and ghes_host == h @@ -112,17 +123,11 @@ def has_github_gitlab_host_env_conflict(hostname: str | None) -> bool: if not github_claims_as_ghes: return False - gitlab_single = os.environ.get("GITLAB_HOST", "").strip().lower().split("/")[0] + gitlab_single = _get_gitlab_single_host() if gitlab_single and gitlab_single == h and is_valid_fqdn(h): return True - raw_list = os.environ.get("APM_GITLAB_HOSTS", "") - for part in raw_list.split(","): - entry = part.strip().lower().split("/")[0] - if entry and entry == h and is_valid_fqdn(entry): - return True - - return False + return any(entry and entry == h and is_valid_fqdn(entry) for entry in _get_gitlab_hosts_list()) def format_github_gitlab_host_conflict_error(hostname: str) -> str: diff --git a/tests/benchmarks/test_lookup_and_cache_benchmarks.py b/tests/benchmarks/test_lookup_and_cache_benchmarks.py new file mode 100644 index 000000000..984e756dd --- /dev/null +++ b/tests/benchmarks/test_lookup_and_cache_benchmarks.py @@ -0,0 +1,204 @@ +"""Performance benchmarks for optimised lookup and cache paths. + +Covers the fixes from the performance audit: +- has_dependency() O(1) index lookup (was O(n) linear scan) +- HttpCache._enforce_size_cap() fast-path (was unconditional full scan) +- github_host env-var helper consolidation (was repeated parsing) + +Run with: uv run pytest tests/benchmarks/test_lookup_and_cache_benchmarks.py -v -m benchmark +""" + +import statistics +import time +from pathlib import Path + +import pytest + +from apm_cli.deps.dependency_graph import DependencyNode, DependencyTree +from apm_cli.models.apm_package import APMPackage +from apm_cli.models.dependency.reference import DependencyReference + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _median_time(fn, *, repeats: int = 7) -> float: + """Return the median wall-clock time of *fn* over *repeats* runs.""" + times: list[float] = [] + for _ in range(repeats): + t0 = time.perf_counter() + fn() + t1 = time.perf_counter() + times.append(t1 - t0) + return statistics.median(times) + + +def _make_node(owner: str, repo: str, depth: int) -> DependencyNode: + dep_ref = DependencyReference.parse(f"{owner}/{repo}#main") + pkg = APMPackage(name=repo, version="1.0.0", source=f"{owner}/{repo}") + return DependencyNode(package=pkg, dependency_ref=dep_ref, depth=depth) + + +def _build_tree(n: int) -> DependencyTree: + """Build a tree with *n* packages.""" + root = APMPackage(name="root", version="1.0.0") + tree = DependencyTree(root_package=root) + for i in range(n): + node = _make_node("org", f"pkg-{i}", depth=(i % 5) + 1) + tree.add_node(node) + return tree + + +# --------------------------------------------------------------------------- +# 1. has_dependency() -- O(1) via _repo_url_index +# --------------------------------------------------------------------------- + + +@pytest.mark.benchmark +class TestHasDependencyScaling: + """has_dependency() must stay O(1) regardless of tree size.""" + + def test_constant_time_lookup(self): + """Lookup time should not grow significantly with tree size.""" + small_tree = _build_tree(50) + large_tree = _build_tree(5000) + + # Look up by repo identity (owner/repo) -- the format DependencyReference uses + small_url = "org/pkg-49" + large_url = "org/pkg-4999" + + t_small = _median_time(lambda: small_tree.has_dependency(small_url), repeats=1000) + t_large = _median_time(lambda: large_tree.has_dependency(large_url), repeats=1000) + + # O(1) means the ratio should be close to 1 regardless of input size. + # With 100x more nodes, an O(n) scan would be ~100x slower. + # Allow generous headroom for measurement noise (< 5x). + if t_small < 1e-9: + pytest.skip("below measurement threshold") + + ratio = t_large / t_small + assert ratio < 5.0, ( + f"has_dependency ratio {ratio:.1f}x for 100x larger tree " + f"suggests O(n) regression (small={t_small:.9f}s, " + f"large={t_large:.9f}s)" + ) + + def test_miss_is_also_constant(self): + """Negative lookups (URL not in tree) must also be O(1).""" + tree = _build_tree(5000) + missing_url = "org/does-not-exist" + + t = _median_time(lambda: tree.has_dependency(missing_url), repeats=1000) + # Must be essentially free (< 100 microseconds median) + assert t < 1e-4, f"Negative lookup took {t:.6f}s" + + def test_index_consistency(self): + """_repo_url_index matches brute-force scan.""" + tree = _build_tree(200) + for node in tree.nodes.values(): + url = node.dependency_ref.repo_url + if url: + assert tree.has_dependency(url) + assert not tree.has_dependency("no/such-repo") + + +# --------------------------------------------------------------------------- +# 2. HttpCache._enforce_size_cap fast-path +# --------------------------------------------------------------------------- + + +@pytest.mark.benchmark +class TestHttpCacheSizeCapFastPath: + """_enforce_size_cap must skip full scan when tracked size is under cap.""" + + def test_store_does_not_scan_when_under_cap(self, tmp_path: Path): + """Repeated stores under the cap should be near-instant.""" + from apm_cli.cache.http_cache import HttpCache + + cache = HttpCache(tmp_path) + body = b"x" * 1024 # 1KB + + # Prime: first store initialises tracking + cache.store("http://example.com/prime", body, headers={"Cache-Control": "max-age=300"}) + + # Now measure subsequent stores (should hit fast-path) + def _store_one(): + url = f"http://example.com/{time.perf_counter_ns()}" + cache.store(url, body, headers={"Cache-Control": "max-age=300"}) + + t = _median_time(_store_one, repeats=20) + # Fast-path store (no directory walk) should be < 50ms + assert t < 0.05, f"Store with fast-path took {t:.4f}s (expected < 50ms)" + + def test_many_stores_scale_linearly(self, tmp_path: Path): + """N stores should scale linearly, not O(N^2) from rescanning.""" + from apm_cli.cache.http_cache import HttpCache + + cache = HttpCache(tmp_path) + body = b"y" * 512 + + def _store_batch(n: int) -> float: + t0 = time.perf_counter() + for i in range(n): + cache.store( + f"http://example.com/batch-{n}-{i}", + body, + headers={"Cache-Control": "max-age=300"}, + ) + return time.perf_counter() - t0 + + t_small = _store_batch(20) + t_large = _store_batch(200) + + if t_small < 1e-6: + pytest.skip("below measurement threshold") + + ratio = t_large / t_small + # With O(N^2), 10x more stores would be ~100x slower. + # With fast-path O(N), expect ~10x. Allow 20x for noise. + assert ratio < 20, ( + f"Scaling ratio {ratio:.1f}x for 10x batch suggests " + f"quadratic regression (small={t_small:.4f}s, large={t_large:.4f}s)" + ) + + +# --------------------------------------------------------------------------- +# 3. github_host env helpers -- consolidated parsing +# --------------------------------------------------------------------------- + + +@pytest.mark.benchmark +class TestGithubHostEnvParsing: + """Env-var parsing helpers avoid redundant string processing.""" + + def test_repeated_calls_are_fast(self, monkeypatch): + """is_gitlab_hostname called 1000x should be well under 50ms.""" + from apm_cli.utils.github_host import is_gitlab_hostname + + monkeypatch.setenv("GITHUB_HOST", "ghe.example.com") + monkeypatch.setenv("APM_GITLAB_HOSTS", "gl1.corp.net,gl2.corp.net,gl3.corp.net") + + def _check_many(): + for _ in range(1000): + is_gitlab_hostname("gl1.corp.net") + is_gitlab_hostname("ghe.example.com") + is_gitlab_hostname("unknown.host.com") + + t = _median_time(_check_many, repeats=5) + # 3000 calls should be < 100ms total (< 33us each) + assert t < 0.1, f"3000 host classifications took {t:.4f}s ({t / 3000 * 1e6:.1f}us/call)" + + def test_conflict_check_fast(self, monkeypatch): + """has_github_gitlab_host_env_conflict 1000x should be < 50ms.""" + from apm_cli.utils.github_host import has_github_gitlab_host_env_conflict + + monkeypatch.setenv("GITHUB_HOST", "shared.example.com") + monkeypatch.setenv("GITLAB_HOST", "shared.example.com") + + def _check_many(): + for _ in range(1000): + has_github_gitlab_host_env_conflict("shared.example.com") + + t = _median_time(_check_many, repeats=5) + assert t < 0.1, f"1000 conflict checks took {t:.4f}s"