@quickchatai/pi-quickreview 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +75 -2
- package/assets/agents/pass-abstraction.md +64 -0
- package/assets/agents/pass-ask-human.md +134 -0
- package/assets/agents/pass-backcompat.md +67 -0
- package/assets/agents/pass-baseline.md +61 -0
- package/assets/agents/pass-behaviour-delta.md +64 -0
- package/assets/agents/pass-bounds.md +36 -0
- package/assets/agents/pass-claims.md +31 -0
- package/assets/agents/pass-concurrency.md +168 -0
- package/assets/agents/pass-consistency.md +57 -0
- package/assets/agents/pass-contracts.md +30 -0
- package/assets/agents/pass-data-safety.md +54 -0
- package/assets/agents/pass-deferred.md +35 -0
- package/assets/agents/pass-delivery.md +37 -0
- package/assets/agents/pass-duplication.md +60 -0
- package/assets/agents/pass-intent.md +63 -0
- package/assets/agents/pass-lint-conformance.md +46 -0
- package/assets/agents/pass-maintainability.md +67 -0
- package/assets/agents/pass-placement.md +68 -0
- package/assets/agents/pass-prod-risk.md +24 -0
- package/assets/agents/pass-rollout.md +32 -0
- package/assets/agents/pass-roundtrip.md +46 -0
- package/assets/agents/pass-security.md +31 -0
- package/assets/agents/pass-simplicity.md +68 -0
- package/assets/agents/pass-statelessness.md +27 -0
- package/assets/agents/pass-style.md +37 -0
- package/assets/agents/pass-sweep.md +57 -0
- package/assets/agents/pass-tests.md +58 -0
- package/assets/agents/pass-ui.md +23 -0
- package/assets/agents/pass-validate.md +50 -0
- package/assets/agents/pass-widened.md +46 -0
- package/assets/agents/scan/pass-scan.md +86 -0
- package/assets/config.example.toml +1067 -0
- package/assets/deploy/reviewer/AGENTS.override.md +12 -0
- package/assets/profiles/default/README.md +21 -0
- package/assets/profiles/default/addenda/.gitkeep +0 -0
- package/assets/profiles/default/profile.toml +17 -0
- package/assets/profiles/example-django/README.md +21 -0
- package/assets/profiles/example-django/addenda/10-migration-discipline.md +13 -0
- package/assets/profiles/example-django/addenda/20-new-column-nullability.md +12 -0
- package/assets/profiles/example-django/addenda/30-test-doctrine.md +12 -0
- package/assets/profiles/example-django/addenda/40-orm-usage.md +19 -0
- package/assets/profiles/example-django/addenda/50-declared-shape.md +10 -0
- package/assets/profiles/example-django/addenda/60-config-placement.md +14 -0
- package/assets/profiles/example-django/heuristics.toml +24 -0
- package/assets/profiles/example-django/profile.toml +9 -0
- package/assets/profiles/fast/README.md +24 -0
- package/assets/profiles/fast/profile.toml +59 -0
- package/assets/profiles/minimal/README.md +17 -0
- package/assets/profiles/minimal/addenda/.gitkeep +0 -0
- package/assets/profiles/minimal/profile.toml +8 -0
- package/assets/profiles/snappy/README.md +24 -0
- package/assets/profiles/snappy/addenda/10-race-as-question.md +6 -0
- package/assets/profiles/snappy/profile.toml +46 -0
- package/assets/prompts/companion-day-open.md +46 -0
- package/assets/prompts/companion-first-run.md +104 -0
- package/assets/prompts/companion.md +821 -0
- package/assets/prompts/conversation-system.md +117 -0
- package/assets/prompts/eod-sweep.md +120 -0
- package/assets/prompts/onboarding.md +74 -0
- package/assets/prompts/pass-common.md +52 -0
- package/assets/prompts/pass-quality-common.md +22 -0
- package/assets/prompts/round-first.md +874 -0
- package/assets/prompts/round-follow-up.md +991 -0
- package/assets/prompts/round-re-review.md +889 -0
- package/assets/prompts/round-recheck.md +196 -0
- package/assets/prompts/round-staleness.md +856 -0
- package/assets/schemas/review-result.v1.json +391 -0
- package/assets/schemas/scan-result.v1.json +421 -0
- package/assets/schemas/version.v1.json +57 -0
- package/assets/seed-manifest.json +19 -0
- package/assets/seeds/agents/pass-baseline.md +61 -0
- package/assets/seeds/agents/pass-contracts.md +30 -0
- package/assets/seeds/agents/pass-data-safety.md +54 -0
- package/assets/seeds/agents/pass-delivery.md +37 -0
- package/assets/seeds/agents/pass-prod-risk.md +24 -0
- package/assets/seeds/agents/pass-security.md +31 -0
- package/assets/seeds/agents/pass-statelessness.md +27 -0
- package/assets/seeds/agents/pass-ui.md +23 -0
- package/assets/seeds/agents/pass-validate.md +50 -0
- package/assets/seeds/prompts/conversation-system.md +117 -0
- package/assets/seeds/prompts/eod-sweep.md +120 -0
- package/assets/seeds/prompts/pass-common.md +52 -0
- package/dist/extensions/bash-guard.d.ts +467 -0
- package/dist/extensions/bash-guard.d.ts.map +1 -0
- package/dist/extensions/bash-guard.js +3058 -0
- package/dist/extensions/bash-guard.js.map +1 -0
- package/dist/extensions/finding-clusters.d.ts +33 -0
- package/dist/extensions/finding-clusters.d.ts.map +1 -0
- package/dist/extensions/finding-clusters.js +218 -0
- package/dist/extensions/finding-clusters.js.map +1 -0
- package/dist/extensions/ledger-pin.d.ts +76 -0
- package/dist/extensions/ledger-pin.d.ts.map +1 -0
- package/dist/extensions/ledger-pin.js +331 -0
- package/dist/extensions/ledger-pin.js.map +1 -0
- package/dist/extensions/lens.d.ts +43 -0
- package/dist/extensions/lens.d.ts.map +1 -0
- package/dist/extensions/lens.js +150 -0
- package/dist/extensions/lens.js.map +1 -0
- package/dist/extensions/pi-tool.d.ts +22 -0
- package/dist/extensions/pi-tool.d.ts.map +1 -0
- package/dist/extensions/pi-tool.js +20 -0
- package/dist/extensions/pi-tool.js.map +1 -0
- package/dist/extensions/quickreview.d.ts +21 -0
- package/dist/extensions/quickreview.d.ts.map +1 -0
- package/dist/extensions/quickreview.js +132 -0
- package/dist/extensions/quickreview.js.map +1 -0
- package/dist/src/anchors.d.ts +54 -0
- package/dist/src/anchors.d.ts.map +1 -0
- package/dist/src/anchors.js +127 -0
- package/dist/src/anchors.js.map +1 -0
- package/dist/src/assetroot.d.ts +16 -0
- package/dist/src/assetroot.d.ts.map +1 -0
- package/dist/src/assetroot.js +77 -0
- package/dist/src/assetroot.js.map +1 -0
- package/dist/src/authwatch.d.ts +61 -0
- package/dist/src/authwatch.d.ts.map +1 -0
- package/dist/src/authwatch.js +210 -0
- package/dist/src/authwatch.js.map +1 -0
- package/dist/src/budget.d.ts +15 -0
- package/dist/src/budget.d.ts.map +1 -0
- package/dist/src/budget.js +32 -0
- package/dist/src/budget.js.map +1 -0
- package/dist/src/casebudget.d.ts +3 -0
- package/dist/src/casebudget.d.ts.map +1 -0
- package/dist/src/casebudget.js +3 -0
- package/dist/src/casebudget.js.map +1 -0
- package/dist/src/cigate.d.ts +116 -0
- package/dist/src/cigate.d.ts.map +1 -0
- package/dist/src/cigate.js +176 -0
- package/dist/src/cigate.js.map +1 -0
- package/dist/src/codespan.d.ts +46 -0
- package/dist/src/codespan.d.ts.map +1 -0
- package/dist/src/codespan.js +75 -0
- package/dist/src/codespan.js.map +1 -0
- package/dist/src/contextpack.d.ts +56 -0
- package/dist/src/contextpack.d.ts.map +1 -0
- package/dist/src/contextpack.js +195 -0
- package/dist/src/contextpack.js.map +1 -0
- package/dist/src/contract.d.ts +8 -0
- package/dist/src/contract.d.ts.map +1 -0
- package/dist/src/contract.js +21 -0
- package/dist/src/contract.js.map +1 -0
- package/dist/src/coverage.d.ts +32 -0
- package/dist/src/coverage.d.ts.map +1 -0
- package/dist/src/coverage.js +85 -0
- package/dist/src/coverage.js.map +1 -0
- package/dist/src/datafence.d.ts +19 -0
- package/dist/src/datafence.d.ts.map +1 -0
- package/dist/src/datafence.js +60 -0
- package/dist/src/datafence.js.map +1 -0
- package/dist/src/dedup.d.ts +65 -0
- package/dist/src/dedup.d.ts.map +1 -0
- package/dist/src/dedup.js +459 -0
- package/dist/src/dedup.js.map +1 -0
- package/dist/src/depsources.d.ts +304 -0
- package/dist/src/depsources.d.ts.map +1 -0
- package/dist/src/depsources.js +3367 -0
- package/dist/src/depsources.js.map +1 -0
- package/dist/src/diffinput.d.ts +10 -0
- package/dist/src/diffinput.d.ts.map +1 -0
- package/dist/src/diffinput.js +172 -0
- package/dist/src/diffinput.js.map +1 -0
- package/dist/src/doubtwords.d.ts +10 -0
- package/dist/src/doubtwords.d.ts.map +1 -0
- package/dist/src/doubtwords.js +13 -0
- package/dist/src/doubtwords.js.map +1 -0
- package/dist/src/ecosystem.d.ts +148 -0
- package/dist/src/ecosystem.d.ts.map +1 -0
- package/dist/src/ecosystem.js +338 -0
- package/dist/src/ecosystem.js.map +1 -0
- package/dist/src/engine/agentdir.d.ts +5 -0
- package/dist/src/engine/agentdir.d.ts.map +1 -0
- package/dist/src/engine/agentdir.js +21 -0
- package/dist/src/engine/agentdir.js.map +1 -0
- package/dist/src/engine/auth.d.ts +214 -0
- package/dist/src/engine/auth.d.ts.map +1 -0
- package/dist/src/engine/auth.js +374 -0
- package/dist/src/engine/auth.js.map +1 -0
- package/dist/src/engine/budget.d.ts +60 -0
- package/dist/src/engine/budget.d.ts.map +1 -0
- package/dist/src/engine/budget.js +113 -0
- package/dist/src/engine/budget.js.map +1 -0
- package/dist/src/engine/capfd.d.ts +6 -0
- package/dist/src/engine/capfd.d.ts.map +1 -0
- package/dist/src/engine/capfd.js +21 -0
- package/dist/src/engine/capfd.js.map +1 -0
- package/dist/src/engine/caseout.d.ts +66 -0
- package/dist/src/engine/caseout.d.ts.map +1 -0
- package/dist/src/engine/caseout.js +107 -0
- package/dist/src/engine/caseout.js.map +1 -0
- package/dist/src/engine/context.d.ts +57 -0
- package/dist/src/engine/context.d.ts.map +1 -0
- package/dist/src/engine/context.js +212 -0
- package/dist/src/engine/context.js.map +1 -0
- package/dist/src/engine/envmap.d.ts +13 -0
- package/dist/src/engine/envmap.d.ts.map +1 -0
- package/dist/src/engine/envmap.js +372 -0
- package/dist/src/engine/envmap.js.map +1 -0
- package/dist/src/engine/extfiles.d.ts +7 -0
- package/dist/src/engine/extfiles.d.ts.map +1 -0
- package/dist/src/engine/extfiles.js +23 -0
- package/dist/src/engine/extfiles.js.map +1 -0
- package/dist/src/engine/keyroute.d.ts +21 -0
- package/dist/src/engine/keyroute.d.ts.map +1 -0
- package/dist/src/engine/keyroute.js +58 -0
- package/dist/src/engine/keyroute.js.map +1 -0
- package/dist/src/engine/lens/cache-group.d.ts +20 -0
- package/dist/src/engine/lens/cache-group.d.ts.map +1 -0
- package/dist/src/engine/lens/cache-group.js +31 -0
- package/dist/src/engine/lens/cache-group.js.map +1 -0
- package/dist/src/engine/lens/cache-run.d.ts +65 -0
- package/dist/src/engine/lens/cache-run.d.ts.map +1 -0
- package/dist/src/engine/lens/cache-run.js +159 -0
- package/dist/src/engine/lens/cache-run.js.map +1 -0
- package/dist/src/engine/lens/deadline.d.ts +54 -0
- package/dist/src/engine/lens/deadline.d.ts.map +1 -0
- package/dist/src/engine/lens/deadline.js +111 -0
- package/dist/src/engine/lens/deadline.js.map +1 -0
- package/dist/src/engine/lens/engine-tools.d.ts +13 -0
- package/dist/src/engine/lens/engine-tools.d.ts.map +1 -0
- package/dist/src/engine/lens/engine-tools.js +37 -0
- package/dist/src/engine/lens/engine-tools.js.map +1 -0
- package/dist/src/engine/lens/env.d.ts +23 -0
- package/dist/src/engine/lens/env.d.ts.map +1 -0
- package/dist/src/engine/lens/env.js +42 -0
- package/dist/src/engine/lens/env.js.map +1 -0
- package/dist/src/engine/lens/launch-plan.d.ts +63 -0
- package/dist/src/engine/lens/launch-plan.d.ts.map +1 -0
- package/dist/src/engine/lens/launch-plan.js +203 -0
- package/dist/src/engine/lens/launch-plan.js.map +1 -0
- package/dist/src/engine/lens/leaf-cache.d.ts +22 -0
- package/dist/src/engine/lens/leaf-cache.d.ts.map +1 -0
- package/dist/src/engine/lens/leaf-cache.js +115 -0
- package/dist/src/engine/lens/leaf-cache.js.map +1 -0
- package/dist/src/engine/lens/permits.d.ts +14 -0
- package/dist/src/engine/lens/permits.d.ts.map +1 -0
- package/dist/src/engine/lens/permits.js +18 -0
- package/dist/src/engine/lens/permits.js.map +1 -0
- package/dist/src/engine/lens/registry.d.ts +55 -0
- package/dist/src/engine/lens/registry.d.ts.map +1 -0
- package/dist/src/engine/lens/registry.js +140 -0
- package/dist/src/engine/lens/registry.js.map +1 -0
- package/dist/src/engine/lens/round-lens.d.ts +16 -0
- package/dist/src/engine/lens/round-lens.d.ts.map +1 -0
- package/dist/src/engine/lens/round-lens.js +46 -0
- package/dist/src/engine/lens/round-lens.js.map +1 -0
- package/dist/src/engine/lens/runlens.d.ts +103 -0
- package/dist/src/engine/lens/runlens.d.ts.map +1 -0
- package/dist/src/engine/lens/runlens.js +538 -0
- package/dist/src/engine/lens/runlens.js.map +1 -0
- package/dist/src/engine/lens/section.d.ts +19 -0
- package/dist/src/engine/lens/section.d.ts.map +1 -0
- package/dist/src/engine/lens/section.js +40 -0
- package/dist/src/engine/lens/section.js.map +1 -0
- package/dist/src/engine/lens/semaphore.d.ts +13 -0
- package/dist/src/engine/lens/semaphore.d.ts.map +1 -0
- package/dist/src/engine/lens/semaphore.js +44 -0
- package/dist/src/engine/lens/semaphore.js.map +1 -0
- package/dist/src/engine/lens/tool.d.ts +34 -0
- package/dist/src/engine/lens/tool.d.ts.map +1 -0
- package/dist/src/engine/lens/tool.js +345 -0
- package/dist/src/engine/lens/tool.js.map +1 -0
- package/dist/src/engine/lens/wave.d.ts +63 -0
- package/dist/src/engine/lens/wave.d.ts.map +1 -0
- package/dist/src/engine/lens/wave.js +95 -0
- package/dist/src/engine/lens/wave.js.map +1 -0
- package/dist/src/engine/livesessions.d.ts +21 -0
- package/dist/src/engine/livesessions.d.ts.map +1 -0
- package/dist/src/engine/livesessions.js +57 -0
- package/dist/src/engine/livesessions.js.map +1 -0
- package/dist/src/engine/livewire.d.ts +18 -0
- package/dist/src/engine/livewire.d.ts.map +1 -0
- package/dist/src/engine/livewire.js +31 -0
- package/dist/src/engine/livewire.js.map +1 -0
- package/dist/src/engine/openable.d.ts +37 -0
- package/dist/src/engine/openable.d.ts.map +1 -0
- package/dist/src/engine/openable.js +120 -0
- package/dist/src/engine/openable.js.map +1 -0
- package/dist/src/engine/pisdk.d.ts +226 -0
- package/dist/src/engine/pisdk.d.ts.map +1 -0
- package/dist/src/engine/pisdk.js +552 -0
- package/dist/src/engine/pisdk.js.map +1 -0
- package/dist/src/engine/port.d.ts +144 -0
- package/dist/src/engine/port.d.ts.map +1 -0
- package/dist/src/engine/port.js +16 -0
- package/dist/src/engine/port.js.map +1 -0
- package/dist/src/engine/prompts.d.ts +159 -0
- package/dist/src/engine/prompts.d.ts.map +1 -0
- package/dist/src/engine/prompts.js +339 -0
- package/dist/src/engine/prompts.js.map +1 -0
- package/dist/src/engine/render.d.ts +10 -0
- package/dist/src/engine/render.d.ts.map +1 -0
- package/dist/src/engine/render.js +23 -0
- package/dist/src/engine/render.js.map +1 -0
- package/dist/src/engine/reviewruntime.d.ts +13 -0
- package/dist/src/engine/reviewruntime.d.ts.map +1 -0
- package/dist/src/engine/reviewruntime.js +35 -0
- package/dist/src/engine/reviewruntime.js.map +1 -0
- package/dist/src/engine/reviewsdk.d.ts +75 -0
- package/dist/src/engine/reviewsdk.d.ts.map +1 -0
- package/dist/src/engine/reviewsdk.js +180 -0
- package/dist/src/engine/reviewsdk.js.map +1 -0
- package/dist/src/engine/round.d.ts +166 -0
- package/dist/src/engine/round.d.ts.map +1 -0
- package/dist/src/engine/round.js +543 -0
- package/dist/src/engine/round.js.map +1 -0
- package/dist/src/engine/roundagents.d.ts +17 -0
- package/dist/src/engine/roundagents.d.ts.map +1 -0
- package/dist/src/engine/roundagents.js +49 -0
- package/dist/src/engine/roundagents.js.map +1 -0
- package/dist/src/engine/roundevents.d.ts +110 -0
- package/dist/src/engine/roundevents.d.ts.map +1 -0
- package/dist/src/engine/roundevents.js +276 -0
- package/dist/src/engine/roundevents.js.map +1 -0
- package/dist/src/engine/roundsdk.d.ts +33 -0
- package/dist/src/engine/roundsdk.d.ts.map +1 -0
- package/dist/src/engine/roundsdk.js +76 -0
- package/dist/src/engine/roundsdk.js.map +1 -0
- package/dist/src/engine/sdkruntime.d.ts +23 -0
- package/dist/src/engine/sdkruntime.d.ts.map +1 -0
- package/dist/src/engine/sdkruntime.js +340 -0
- package/dist/src/engine/sdkruntime.js.map +1 -0
- package/dist/src/engine/select.d.ts +16 -0
- package/dist/src/engine/select.d.ts.map +1 -0
- package/dist/src/engine/select.js +32 -0
- package/dist/src/engine/select.js.map +1 -0
- package/dist/src/engine/sessionwrap.d.ts +4 -0
- package/dist/src/engine/sessionwrap.d.ts.map +1 -0
- package/dist/src/engine/sessionwrap.js +31 -0
- package/dist/src/engine/sessionwrap.js.map +1 -0
- package/dist/src/engine/toolfence.d.ts +90 -0
- package/dist/src/engine/toolfence.d.ts.map +1 -0
- package/dist/src/engine/toolfence.js +378 -0
- package/dist/src/engine/toolfence.js.map +1 -0
- package/dist/src/engine/tooluid.d.ts +128 -0
- package/dist/src/engine/tooluid.d.ts.map +1 -0
- package/dist/src/engine/tooluid.js +401 -0
- package/dist/src/engine/tooluid.js.map +1 -0
- package/dist/src/envstrip.d.ts +10 -0
- package/dist/src/envstrip.d.ts.map +1 -0
- package/dist/src/envstrip.js +15 -0
- package/dist/src/envstrip.js.map +1 -0
- package/dist/src/escalationprompt.d.ts +22 -0
- package/dist/src/escalationprompt.d.ts.map +1 -0
- package/dist/src/escalationprompt.js +54 -0
- package/dist/src/escalationprompt.js.map +1 -0
- package/dist/src/execfn.d.ts +29 -0
- package/dist/src/execfn.d.ts.map +1 -0
- package/dist/src/execfn.js +55 -0
- package/dist/src/execfn.js.map +1 -0
- package/dist/src/finding-chain.d.ts +40 -0
- package/dist/src/finding-chain.d.ts.map +1 -0
- package/dist/src/finding-chain.js +76 -0
- package/dist/src/finding-chain.js.map +1 -0
- package/dist/src/finding-row.d.ts +24 -0
- package/dist/src/finding-row.d.ts.map +1 -0
- package/dist/src/finding-row.js +26 -0
- package/dist/src/finding-row.js.map +1 -0
- package/dist/src/findingkey.d.ts +99 -0
- package/dist/src/findingkey.d.ts.map +1 -0
- package/dist/src/findingkey.js +210 -0
- package/dist/src/findingkey.js.map +1 -0
- package/dist/src/findingsrecord.d.ts +175 -0
- package/dist/src/findingsrecord.d.ts.map +1 -0
- package/dist/src/findingsrecord.js +410 -0
- package/dist/src/findingsrecord.js.map +1 -0
- package/dist/src/followup-mode.d.ts +4 -0
- package/dist/src/followup-mode.d.ts.map +1 -0
- package/dist/src/followup-mode.js +6 -0
- package/dist/src/followup-mode.js.map +1 -0
- package/dist/src/gate.d.ts +199 -0
- package/dist/src/gate.d.ts.map +1 -0
- package/dist/src/gate.js +315 -0
- package/dist/src/gate.js.map +1 -0
- package/dist/src/ghcred.d.ts +58 -0
- package/dist/src/ghcred.d.ts.map +1 -0
- package/dist/src/ghcred.js +139 -0
- package/dist/src/ghcred.js.map +1 -0
- package/dist/src/githubAuth.d.ts +32 -0
- package/dist/src/githubAuth.d.ts.map +1 -0
- package/dist/src/githubAuth.js +116 -0
- package/dist/src/githubAuth.js.map +1 -0
- package/dist/src/githubcore.d.ts +20 -0
- package/dist/src/githubcore.d.ts.map +1 -0
- package/dist/src/githubcore.js +20 -0
- package/dist/src/githubcore.js.map +1 -0
- package/dist/src/glob.d.ts +11 -0
- package/dist/src/glob.d.ts.map +1 -0
- package/dist/src/glob.js +69 -0
- package/dist/src/glob.js.map +1 -0
- package/dist/src/guards.d.ts +93 -0
- package/dist/src/guards.d.ts.map +1 -0
- package/dist/src/guards.js +306 -0
- package/dist/src/guards.js.map +1 -0
- package/dist/src/heartbeat.d.ts +29 -0
- package/dist/src/heartbeat.d.ts.map +1 -0
- package/dist/src/heartbeat.js +87 -0
- package/dist/src/heartbeat.js.map +1 -0
- package/dist/src/heuristics.d.ts +247 -0
- package/dist/src/heuristics.d.ts.map +1 -0
- package/dist/src/heuristics.js +927 -0
- package/dist/src/heuristics.js.map +1 -0
- package/dist/src/homes.d.ts +181 -0
- package/dist/src/homes.d.ts.map +1 -0
- package/dist/src/homes.js +242 -0
- package/dist/src/homes.js.map +1 -0
- package/dist/src/hunkheader.d.ts +11 -0
- package/dist/src/hunkheader.d.ts.map +1 -0
- package/dist/src/hunkheader.js +18 -0
- package/dist/src/hunkheader.js.map +1 -0
- package/dist/src/include.d.ts +435 -0
- package/dist/src/include.d.ts.map +1 -0
- package/dist/src/include.js +1692 -0
- package/dist/src/include.js.map +1 -0
- package/dist/src/launchvocab.d.ts +36 -0
- package/dist/src/launchvocab.d.ts.map +1 -0
- package/dist/src/launchvocab.js +59 -0
- package/dist/src/launchvocab.js.map +1 -0
- package/dist/src/ledgercarry.d.ts +24 -0
- package/dist/src/ledgercarry.d.ts.map +1 -0
- package/dist/src/ledgercarry.js +109 -0
- package/dist/src/ledgercarry.js.map +1 -0
- package/dist/src/lensids.d.ts +7 -0
- package/dist/src/lensids.d.ts.map +1 -0
- package/dist/src/lensids.js +53 -0
- package/dist/src/lensids.js.map +1 -0
- package/dist/src/lensplanning.d.ts +8 -0
- package/dist/src/lensplanning.d.ts.map +1 -0
- package/dist/src/lensplanning.js +126 -0
- package/dist/src/lensplanning.js.map +1 -0
- package/dist/src/lensresult.d.ts +94 -0
- package/dist/src/lensresult.d.ts.map +1 -0
- package/dist/src/lensresult.js +162 -0
- package/dist/src/lensresult.js.map +1 -0
- package/dist/src/lenstuning.d.ts +97 -0
- package/dist/src/lenstuning.d.ts.map +1 -0
- package/dist/src/lenstuning.js +326 -0
- package/dist/src/lenstuning.js.map +1 -0
- package/dist/src/lintconfig.d.ts +31 -0
- package/dist/src/lintconfig.d.ts.map +1 -0
- package/dist/src/lintconfig.js +543 -0
- package/dist/src/lintconfig.js.map +1 -0
- package/dist/src/live/events.d.ts +188 -0
- package/dist/src/live/events.d.ts.map +1 -0
- package/dist/src/live/events.js +136 -0
- package/dist/src/live/events.js.map +1 -0
- package/dist/src/live/writer.d.ts +54 -0
- package/dist/src/live/writer.d.ts.map +1 -0
- package/dist/src/live/writer.js +516 -0
- package/dist/src/live/writer.js.map +1 -0
- package/dist/src/localreview.d.ts +128 -0
- package/dist/src/localreview.d.ts.map +1 -0
- package/dist/src/localreview.js +426 -0
- package/dist/src/localreview.js.map +1 -0
- package/dist/src/loosenedtests.d.ts +13 -0
- package/dist/src/loosenedtests.d.ts.map +1 -0
- package/dist/src/loosenedtests.js +188 -0
- package/dist/src/loosenedtests.js.map +1 -0
- package/dist/src/memoryport.d.ts +264 -0
- package/dist/src/memoryport.d.ts.map +1 -0
- package/dist/src/memoryport.js +445 -0
- package/dist/src/memoryport.js.map +1 -0
- package/dist/src/migrations.d.ts +106 -0
- package/dist/src/migrations.d.ts.map +1 -0
- package/dist/src/migrations.js +373 -0
- package/dist/src/migrations.js.map +1 -0
- package/dist/src/oneshot.d.ts +12 -0
- package/dist/src/oneshot.d.ts.map +1 -0
- package/dist/src/oneshot.js +8 -0
- package/dist/src/oneshot.js.map +1 -0
- package/dist/src/packing.d.ts +11 -0
- package/dist/src/packing.d.ts.map +1 -0
- package/dist/src/packing.js +31 -0
- package/dist/src/packing.js.map +1 -0
- package/dist/src/passes.d.ts +16 -0
- package/dist/src/passes.d.ts.map +1 -0
- package/dist/src/passes.js +104 -0
- package/dist/src/passes.js.map +1 -0
- package/dist/src/passtasks.d.ts +68 -0
- package/dist/src/passtasks.d.ts.map +1 -0
- package/dist/src/passtasks.js +360 -0
- package/dist/src/passtasks.js.map +1 -0
- package/dist/src/pathinside.d.ts +5 -0
- package/dist/src/pathinside.d.ts.map +1 -0
- package/dist/src/pathinside.js +9 -0
- package/dist/src/pathinside.js.map +1 -0
- package/dist/src/pibuiltins.d.ts +36 -0
- package/dist/src/pibuiltins.d.ts.map +1 -0
- package/dist/src/pibuiltins.js +83 -0
- package/dist/src/pibuiltins.js.map +1 -0
- package/dist/src/plankey.d.ts +4 -0
- package/dist/src/plankey.d.ts.map +1 -0
- package/dist/src/plankey.js +10 -0
- package/dist/src/plankey.js.map +1 -0
- package/dist/src/planner.d.ts +375 -0
- package/dist/src/planner.d.ts.map +1 -0
- package/dist/src/planner.js +722 -0
- package/dist/src/planner.js.map +1 -0
- package/dist/src/plugin/args.d.ts +19 -0
- package/dist/src/plugin/args.d.ts.map +1 -0
- package/dist/src/plugin/args.js +107 -0
- package/dist/src/plugin/args.js.map +1 -0
- package/dist/src/plugin/index.d.ts +3 -0
- package/dist/src/plugin/index.d.ts.map +1 -0
- package/dist/src/plugin/index.js +14 -0
- package/dist/src/plugin/index.js.map +1 -0
- package/dist/src/plugin/login.d.ts +39 -0
- package/dist/src/plugin/login.d.ts.map +1 -0
- package/dist/src/plugin/login.js +73 -0
- package/dist/src/plugin/login.js.map +1 -0
- package/dist/src/plugin/review.d.ts +27 -0
- package/dist/src/plugin/review.d.ts.map +1 -0
- package/dist/src/plugin/review.js +298 -0
- package/dist/src/plugin/review.js.map +1 -0
- package/dist/src/plugin/runner.d.ts +6 -0
- package/dist/src/plugin/runner.d.ts.map +1 -0
- package/dist/src/plugin/runner.js +65 -0
- package/dist/src/plugin/runner.js.map +1 -0
- package/dist/src/plugin/scan.d.ts +79 -0
- package/dist/src/plugin/scan.d.ts.map +1 -0
- package/dist/src/plugin/scan.js +348 -0
- package/dist/src/plugin/scan.js.map +1 -0
- package/dist/src/plugin/session.d.ts +56 -0
- package/dist/src/plugin/session.d.ts.map +1 -0
- package/dist/src/plugin/session.js +2 -0
- package/dist/src/plugin/session.js.map +1 -0
- package/dist/src/plugin/setup.d.ts +19 -0
- package/dist/src/plugin/setup.d.ts.map +1 -0
- package/dist/src/plugin/setup.js +142 -0
- package/dist/src/plugin/setup.js.map +1 -0
- package/dist/src/plugin/versiongate.d.ts +26 -0
- package/dist/src/plugin/versiongate.d.ts.map +1 -0
- package/dist/src/plugin/versiongate.js +83 -0
- package/dist/src/plugin/versiongate.js.map +1 -0
- package/dist/src/postingkey.d.ts +8 -0
- package/dist/src/postingkey.d.ts.map +1 -0
- package/dist/src/postingkey.js +25 -0
- package/dist/src/postingkey.js.map +1 -0
- package/dist/src/postingpolicy.d.ts +108 -0
- package/dist/src/postingpolicy.d.ts.map +1 -0
- package/dist/src/postingpolicy.js +341 -0
- package/dist/src/postingpolicy.js.map +1 -0
- package/dist/src/pr-identity.d.ts +3 -0
- package/dist/src/pr-identity.d.ts.map +1 -0
- package/dist/src/pr-identity.js +9 -0
- package/dist/src/pr-identity.js.map +1 -0
- package/dist/src/prdelta.d.ts +181 -0
- package/dist/src/prdelta.d.ts.map +1 -0
- package/dist/src/prdelta.js +501 -0
- package/dist/src/prdelta.js.map +1 -0
- package/dist/src/pricing.d.ts +94 -0
- package/dist/src/pricing.d.ts.map +1 -0
- package/dist/src/pricing.js +209 -0
- package/dist/src/pricing.js.map +1 -0
- package/dist/src/priorfindings.d.ts +167 -0
- package/dist/src/priorfindings.d.ts.map +1 -0
- package/dist/src/priorfindings.js +445 -0
- package/dist/src/priorfindings.js.map +1 -0
- package/dist/src/priortasks.d.ts +31 -0
- package/dist/src/priortasks.d.ts.map +1 -0
- package/dist/src/priortasks.js +119 -0
- package/dist/src/priortasks.js.map +1 -0
- package/dist/src/profilelookup.d.ts +59 -0
- package/dist/src/profilelookup.d.ts.map +1 -0
- package/dist/src/profilelookup.js +98 -0
- package/dist/src/profilelookup.js.map +1 -0
- package/dist/src/profileoverrides.d.ts +91 -0
- package/dist/src/profileoverrides.d.ts.map +1 -0
- package/dist/src/profileoverrides.js +313 -0
- package/dist/src/profileoverrides.js.map +1 -0
- package/dist/src/profiles.d.ts +144 -0
- package/dist/src/profiles.d.ts.map +1 -0
- package/dist/src/profiles.js +604 -0
- package/dist/src/profiles.js.map +1 -0
- package/dist/src/promptdoc.d.ts +3 -0
- package/dist/src/promptdoc.d.ts.map +1 -0
- package/dist/src/promptdoc.js +18 -0
- package/dist/src/promptdoc.js.map +1 -0
- package/dist/src/prtarget.d.ts +70 -0
- package/dist/src/prtarget.d.ts.map +1 -0
- package/dist/src/prtarget.js +181 -0
- package/dist/src/prtarget.js.map +1 -0
- package/dist/src/recheckorder.d.ts +32 -0
- package/dist/src/recheckorder.d.ts.map +1 -0
- package/dist/src/recheckorder.js +5 -0
- package/dist/src/recheckorder.js.map +1 -0
- package/dist/src/reviewapi.d.ts +207 -0
- package/dist/src/reviewapi.d.ts.map +1 -0
- package/dist/src/reviewapi.js +939 -0
- package/dist/src/reviewapi.js.map +1 -0
- package/dist/src/reviewbotfindings.d.ts +33 -0
- package/dist/src/reviewbotfindings.d.ts.map +1 -0
- package/dist/src/reviewbotfindings.js +80 -0
- package/dist/src/reviewbotfindings.js.map +1 -0
- package/dist/src/reviewconfig.d.ts +741 -0
- package/dist/src/reviewconfig.d.ts.map +1 -0
- package/dist/src/reviewconfig.js +797 -0
- package/dist/src/reviewconfig.js.map +1 -0
- package/dist/src/reviewdelta.d.ts +51 -0
- package/dist/src/reviewdelta.d.ts.map +1 -0
- package/dist/src/reviewdelta.js +119 -0
- package/dist/src/reviewdelta.js.map +1 -0
- package/dist/src/reviewdest.d.ts +86 -0
- package/dist/src/reviewdest.d.ts.map +1 -0
- package/dist/src/reviewdest.js +142 -0
- package/dist/src/reviewdest.js.map +1 -0
- package/dist/src/reviewerstub.d.ts +12 -0
- package/dist/src/reviewerstub.d.ts.map +1 -0
- package/dist/src/reviewerstub.js +28 -0
- package/dist/src/reviewerstub.js.map +1 -0
- package/dist/src/reviewhost.d.ts +58 -0
- package/dist/src/reviewhost.d.ts.map +1 -0
- package/dist/src/reviewhost.js +135 -0
- package/dist/src/reviewhost.js.map +1 -0
- package/dist/src/reviewpost.d.ts +79 -0
- package/dist/src/reviewpost.d.ts.map +1 -0
- package/dist/src/reviewpost.js +233 -0
- package/dist/src/reviewpost.js.map +1 -0
- package/dist/src/reviewrender.d.ts +154 -0
- package/dist/src/reviewrender.d.ts.map +1 -0
- package/dist/src/reviewrender.js +480 -0
- package/dist/src/reviewrender.js.map +1 -0
- package/dist/src/reviewstate.d.ts +62 -0
- package/dist/src/reviewstate.d.ts.map +1 -0
- package/dist/src/reviewstate.js +167 -0
- package/dist/src/reviewstate.js.map +1 -0
- package/dist/src/reviewstore.d.ts +17 -0
- package/dist/src/reviewstore.d.ts.map +1 -0
- package/dist/src/reviewstore.js +105 -0
- package/dist/src/reviewstore.js.map +1 -0
- package/dist/src/round-progress.d.ts +120 -0
- package/dist/src/round-progress.d.ts.map +1 -0
- package/dist/src/round-progress.js +172 -0
- package/dist/src/round-progress.js.map +1 -0
- package/dist/src/round-stage-deadlines.d.ts +12 -0
- package/dist/src/round-stage-deadlines.d.ts.map +1 -0
- package/dist/src/round-stage-deadlines.js +10 -0
- package/dist/src/round-stage-deadlines.js.map +1 -0
- package/dist/src/session-command.d.ts +19 -0
- package/dist/src/session-command.d.ts.map +1 -0
- package/dist/src/session-command.js +20 -0
- package/dist/src/session-command.js.map +1 -0
- package/dist/src/settledread.d.ts +61 -0
- package/dist/src/settledread.d.ts.map +1 -0
- package/dist/src/settledread.js +135 -0
- package/dist/src/settledread.js.map +1 -0
- package/dist/src/severity.d.ts +167 -0
- package/dist/src/severity.d.ts.map +1 -0
- package/dist/src/severity.js +364 -0
- package/dist/src/severity.js.map +1 -0
- package/dist/src/severityrank.d.ts +8 -0
- package/dist/src/severityrank.d.ts.map +1 -0
- package/dist/src/severityrank.js +16 -0
- package/dist/src/severityrank.js.map +1 -0
- package/dist/src/standing-review.d.ts +45 -0
- package/dist/src/standing-review.d.ts.map +1 -0
- package/dist/src/standing-review.js +43 -0
- package/dist/src/standing-review.js.map +1 -0
- package/dist/src/storerows.d.ts +225 -0
- package/dist/src/storerows.d.ts.map +1 -0
- package/dist/src/storerows.js +7 -0
- package/dist/src/storerows.js.map +1 -0
- package/dist/src/styleguide.d.ts +25 -0
- package/dist/src/styleguide.d.ts.map +1 -0
- package/dist/src/styleguide.js +272 -0
- package/dist/src/styleguide.js.map +1 -0
- package/dist/src/sweepstatus.d.ts +42 -0
- package/dist/src/sweepstatus.d.ts.map +1 -0
- package/dist/src/sweepstatus.js +97 -0
- package/dist/src/sweepstatus.js.map +1 -0
- package/dist/src/templates.d.ts +243 -0
- package/dist/src/templates.d.ts.map +1 -0
- package/dist/src/templates.js +847 -0
- package/dist/src/templates.js.map +1 -0
- package/dist/src/types.d.ts +639 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +90 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/unifieddiff.d.ts +22 -0
- package/dist/src/unifieddiff.d.ts.map +1 -0
- package/dist/src/unifieddiff.js +176 -0
- package/dist/src/unifieddiff.js.map +1 -0
- package/dist/src/yieldloop.d.ts +2 -0
- package/dist/src/yieldloop.d.ts.map +1 -0
- package/dist/src/yieldloop.js +14 -0
- package/dist/src/yieldloop.js.map +1 -0
- package/package.json +60 -4
|
@@ -0,0 +1,3367 @@
|
|
|
1
|
+
// Third-party library SOURCES for a review round — read-only, at the PR's
|
|
2
|
+
// locked versions.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: a PR worktree (`wt/pr-<n>`) is a bare checkout — no
|
|
5
|
+
// node_modules, no venv — because the reviewer never installs or runs
|
|
6
|
+
// anything (scoping rule 2). Without library sources every pass would guess
|
|
7
|
+
// what a library actually does: which encoding names `jschardet` returns,
|
|
8
|
+
// which encodings `iconv-lite` accepts, what a default is, when a call throws
|
|
9
|
+
// — knowledge a whole class of defects turns on. This module closes the gap
|
|
10
|
+
// WITHOUT crossing the no-exec line: the daemon calls it while preparing
|
|
11
|
+
// the worktree, it downloads the
|
|
12
|
+
// SOURCE of the packages this PR actually touches, at the versions the PR's
|
|
13
|
+
// lockfiles pin, and the round prompt lists where they landed so a pass can
|
|
14
|
+
// read — and cite — the real code instead of its memory of it.
|
|
15
|
+
//
|
|
16
|
+
// The boundary, stated once because every choice below serves it:
|
|
17
|
+
//
|
|
18
|
+
// * It DOWNLOADS archives over HTTPS from exactly two registries
|
|
19
|
+
// (registry.npmjs.org, pypi.org / files.pythonhosted.org) and UNPACKS
|
|
20
|
+
// them as plain files in Node. It never runs a package manager, an
|
|
21
|
+
// install script, setup.py or a build backend, and it spawns no external
|
|
22
|
+
// tool (no `tar`, no `unzip`) — nothing from an archive ever executes.
|
|
23
|
+
// * Only readable text survives unpacking (source, typings, JSON, docs),
|
|
24
|
+
// and only as REGULAR FILES: symlinks, hardlinks and devices are never
|
|
25
|
+
// created, and every entry path is normalised and confined to the target
|
|
26
|
+
// directory — a registry archive is untrusted input like any other.
|
|
27
|
+
// * It is TOTAL. `prepareDependencySources` never throws: a dead registry,
|
|
28
|
+
// a malformed lockfile, a hostile archive, a blown time budget — each
|
|
29
|
+
// becomes one `skipped` entry with a short reason and the round proceeds.
|
|
30
|
+
// Missing library source costs one finding at worst; a stuck round costs
|
|
31
|
+
// the whole review.
|
|
32
|
+
//
|
|
33
|
+
// Shape: planning (`planDependencyRequests`) is a pure function of the tree
|
|
34
|
+
// and the changed-file list, so which packages matter is unit-testable
|
|
35
|
+
// without a network; fetching takes `fetch` by injection; rendering is a
|
|
36
|
+
// pure function of the manifest. The lower-level parsers (lockfiles, semver,
|
|
37
|
+
// tar, zip) are exported because each is a contract worth pinning on its own.
|
|
38
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
39
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync, } from "node:fs";
|
|
40
|
+
import { basename, dirname, join, posix, resolve, sep } from "node:path";
|
|
41
|
+
import * as zlib from "node:zlib";
|
|
42
|
+
import { parse as parseToml } from "smol-toml";
|
|
43
|
+
import { flatControls } from "./codespan.js";
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Limits and fixed endpoints
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
const MiB = 1024 * 1024;
|
|
48
|
+
const DEFAULT_MAX_PACKAGES = 12;
|
|
49
|
+
const DEFAULT_MAX_ARCHIVE_BYTES = 15 * MiB;
|
|
50
|
+
const DEFAULT_MAX_UNPACKED_BYTES = 40 * MiB;
|
|
51
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
52
|
+
const DEFAULT_MAX_CACHE_BYTES = 2048 * MiB;
|
|
53
|
+
/** Longer than any round lives (`round_deadline_min`, 75 by default): a
|
|
54
|
+
* source advertised to a running round was touched at its prepare, so it
|
|
55
|
+
* stays readable until that round is over. */
|
|
56
|
+
const DEFAULT_CACHE_IN_USE_MS = 2 * 60 * 60 * 1000;
|
|
57
|
+
const PER_REQUEST_TIMEOUT_MS = 20_000;
|
|
58
|
+
/** Registry JSON is not an archive, but it is still untrusted and unbounded. */
|
|
59
|
+
const METADATA_MAX_BYTES = 32 * MiB;
|
|
60
|
+
/**
|
|
61
|
+
* The DECOMPRESSED ceiling for one archive. The kept-bytes cap only counts
|
|
62
|
+
* source we keep; this one stops a gzip bomb before it is in memory.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_EXPANDED_ARCHIVE_BYTES = 256 * MiB;
|
|
65
|
+
const MAX_KEPT_FILE_BYTES = 2 * MiB;
|
|
66
|
+
/** Kept files per package: bytes alone let an archive of empty files spend
|
|
67
|
+
* inodes without limit. Generous — a real package's sources are far fewer. */
|
|
68
|
+
const MAX_KEPT_FILES = 50_000;
|
|
69
|
+
/** What the cache cap charges per kept file or created directory on top of
|
|
70
|
+
* its bytes (a block and an inode), so an entry of many tiny files — or of
|
|
71
|
+
* deep directory chains — is sized by what it occupies. */
|
|
72
|
+
const CACHE_FILE_OVERHEAD_BYTES = 4096;
|
|
73
|
+
const MAX_CHANGED_FILES = 300;
|
|
74
|
+
const MAX_SOURCE_FILE_BYTES = 1_000_000;
|
|
75
|
+
/** package-lock.json in a large monorepo is routinely several MB. */
|
|
76
|
+
const MAX_MANIFEST_BYTES = 64 * MiB;
|
|
77
|
+
const CONCURRENCY = 4;
|
|
78
|
+
const STALE_TMP_MS = 60 * 60 * 1000;
|
|
79
|
+
const MARKER = ".qcr-complete";
|
|
80
|
+
const USER_AGENT = "quickreviewd-depsources";
|
|
81
|
+
const NPM_REGISTRY = "https://registry.npmjs.org/";
|
|
82
|
+
const NPM_ABBREVIATED = "application/vnd.npm.install-v1+json";
|
|
83
|
+
const PYPI_ORIGIN = "https://pypi.org/";
|
|
84
|
+
const PYPI_API = `${PYPI_ORIGIN}pypi/`;
|
|
85
|
+
const PYPI_FILES = "https://files.pythonhosted.org/";
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Small utilities
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
/** A failure that is an ANSWER: it becomes a `skipped` entry with this reason. */
|
|
90
|
+
class SkipError extends Error {
|
|
91
|
+
}
|
|
92
|
+
function isObj(x) {
|
|
93
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
94
|
+
}
|
|
95
|
+
function str(x) {
|
|
96
|
+
return typeof x === "string" ? x : undefined;
|
|
97
|
+
}
|
|
98
|
+
function clip(s, n = 48) {
|
|
99
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
100
|
+
}
|
|
101
|
+
function short(e) {
|
|
102
|
+
return clip(e instanceof Error ? e.message : String(e), 120);
|
|
103
|
+
}
|
|
104
|
+
function fmtBytes(n) {
|
|
105
|
+
return n >= MiB
|
|
106
|
+
? `${(n / MiB).toFixed(n >= 10 * MiB ? 0 : 1)} MiB`
|
|
107
|
+
: `${Math.ceil(n / 1024)} KiB`;
|
|
108
|
+
}
|
|
109
|
+
function hostOf(url) {
|
|
110
|
+
try {
|
|
111
|
+
return new URL(url).host;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** PEP 503 name normalisation, also applied to import names (`_`/`.` → `-`). */
|
|
118
|
+
export function normPy(name) {
|
|
119
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
120
|
+
}
|
|
121
|
+
/** Repo-relative, forward-slashed, never escaping the tree — or null. */
|
|
122
|
+
function normalizeRel(p) {
|
|
123
|
+
if (!p || p.includes("\0") || p.includes("\\") || p.startsWith("/"))
|
|
124
|
+
return null;
|
|
125
|
+
const n = posix.normalize(p);
|
|
126
|
+
if (n === ".." || n.startsWith("../"))
|
|
127
|
+
return null;
|
|
128
|
+
return n === "." ? "" : n;
|
|
129
|
+
}
|
|
130
|
+
function relDirOf(relFile) {
|
|
131
|
+
const d = posix.dirname(relFile);
|
|
132
|
+
return d === "." ? "" : d;
|
|
133
|
+
}
|
|
134
|
+
/** The file's own dir, then each parent up to and including the tree root (""). */
|
|
135
|
+
function ancestors(relDir) {
|
|
136
|
+
const out = [];
|
|
137
|
+
let d = relDir;
|
|
138
|
+
for (;;) {
|
|
139
|
+
out.push(d);
|
|
140
|
+
if (!d)
|
|
141
|
+
break;
|
|
142
|
+
const p = posix.dirname(d);
|
|
143
|
+
d = p === "." ? "" : p;
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Import extraction
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
const JS_EXTS = new Set([
|
|
151
|
+
".js",
|
|
152
|
+
".jsx",
|
|
153
|
+
".mjs",
|
|
154
|
+
".cjs",
|
|
155
|
+
".ts",
|
|
156
|
+
".tsx",
|
|
157
|
+
".mts",
|
|
158
|
+
".cts",
|
|
159
|
+
".vue",
|
|
160
|
+
".svelte",
|
|
161
|
+
]);
|
|
162
|
+
const PY_EXTS = new Set([".py", ".pyi"]);
|
|
163
|
+
const NODE_BUILTINS = new Set([
|
|
164
|
+
"assert",
|
|
165
|
+
"async_hooks",
|
|
166
|
+
"buffer",
|
|
167
|
+
"child_process",
|
|
168
|
+
"cluster",
|
|
169
|
+
"console",
|
|
170
|
+
"constants",
|
|
171
|
+
"crypto",
|
|
172
|
+
"dgram",
|
|
173
|
+
"diagnostics_channel",
|
|
174
|
+
"dns",
|
|
175
|
+
"domain",
|
|
176
|
+
"events",
|
|
177
|
+
"fs",
|
|
178
|
+
"http",
|
|
179
|
+
"http2",
|
|
180
|
+
"https",
|
|
181
|
+
"inspector",
|
|
182
|
+
"module",
|
|
183
|
+
"net",
|
|
184
|
+
"os",
|
|
185
|
+
"path",
|
|
186
|
+
"perf_hooks",
|
|
187
|
+
"process",
|
|
188
|
+
"punycode",
|
|
189
|
+
"querystring",
|
|
190
|
+
"readline",
|
|
191
|
+
"repl",
|
|
192
|
+
"stream",
|
|
193
|
+
"string_decoder",
|
|
194
|
+
"sys",
|
|
195
|
+
"timers",
|
|
196
|
+
"tls",
|
|
197
|
+
"trace_events",
|
|
198
|
+
"tty",
|
|
199
|
+
"url",
|
|
200
|
+
"util",
|
|
201
|
+
"v8",
|
|
202
|
+
"vm",
|
|
203
|
+
"wasi",
|
|
204
|
+
"worker_threads",
|
|
205
|
+
"zlib",
|
|
206
|
+
]);
|
|
207
|
+
// `import … from 'x'` / `export … from 'x'`, `import 'x'`, `require('x')`,
|
|
208
|
+
// `import('x')`. Deliberately a regex scan, not a parser: a false positive
|
|
209
|
+
// costs nothing (an npm import must still be DECLARED in a manifest or
|
|
210
|
+
// lockfile to be requested, except in a tree with neither, where Resolver.npm
|
|
211
|
+
// asks the registry for the bare name), a missed import costs one library.
|
|
212
|
+
//
|
|
213
|
+
// The `… from 'x'` form is NOT one regex: an `import … from` pattern needs an
|
|
214
|
+
// unbounded gap, and a lazy gap re-scans the whole suffix at every `import`
|
|
215
|
+
// token that no `from` follows — quadratic on a PR author's file, on the
|
|
216
|
+
// daemon's event loop, before the preparation deadline exists. Instead each
|
|
217
|
+
// `from 'x'` is found once and the text back to the previous `;` or quote is
|
|
218
|
+
// checked for an `import`/`export` head: the segments are disjoint, so the
|
|
219
|
+
// scan is linear.
|
|
220
|
+
// The specifier admits no whitespace (no package name has any), so a quoted
|
|
221
|
+
// `'from'` in ordinary code cannot swallow the next real `from '…'` as its spec.
|
|
222
|
+
const JS_FROM_RE = /\bfrom\s*(['"`])([^'"`\s]+)\1/g;
|
|
223
|
+
const JS_FROM_HEAD_RE = /(?:^|[^\w$.])(?:import|export)\s/;
|
|
224
|
+
const JS_IMPORT_RES = [
|
|
225
|
+
/(?:^|[^\w$.])import\s*(['"`])([^'"`\n]+)\1/g,
|
|
226
|
+
/(?:^|[^\w$.])(?:require|import)\s*\(\s*(['"`])([^'"`\n]+)\1\s*\)/g,
|
|
227
|
+
];
|
|
228
|
+
/** Raw module specifiers imported by a JS/TS source, each once: the `… from`
|
|
229
|
+
* forms in source order, then the bare `import "x"` / `require("x")` /
|
|
230
|
+
* `import("x")` forms in theirs. */
|
|
231
|
+
export function extractJsImports(src) {
|
|
232
|
+
// Whole-line comments only: stripping `/* … */` spans is unsafe without a
|
|
233
|
+
// tokenizer (a glob string like 'src/*' would open a "comment" that eats
|
|
234
|
+
// real imports), and inline comments are harmless here.
|
|
235
|
+
const text = src
|
|
236
|
+
.split("\n")
|
|
237
|
+
.filter((l) => {
|
|
238
|
+
const t = l.trimStart();
|
|
239
|
+
return !(t.startsWith("//") || t.startsWith("*") || t.startsWith("/*"));
|
|
240
|
+
})
|
|
241
|
+
.join("\n");
|
|
242
|
+
const out = [];
|
|
243
|
+
const seen = new Set();
|
|
244
|
+
const add = (spec) => {
|
|
245
|
+
if (spec !== undefined && !seen.has(spec)) {
|
|
246
|
+
seen.add(spec);
|
|
247
|
+
out.push(spec);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
for (const m of text.matchAll(JS_FROM_RE)) {
|
|
251
|
+
let start = m.index;
|
|
252
|
+
while (start > 0 && !";'\"`".includes(text[start - 1] ?? ""))
|
|
253
|
+
start--;
|
|
254
|
+
if (JS_FROM_HEAD_RE.test(text.slice(start, m.index)))
|
|
255
|
+
add(m[2]);
|
|
256
|
+
}
|
|
257
|
+
for (const re of JS_IMPORT_RES)
|
|
258
|
+
for (const m of text.matchAll(re))
|
|
259
|
+
add(m[2]);
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
const NPM_NAME_RE = /^(?:@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/i;
|
|
263
|
+
/**
|
|
264
|
+
* The npm package a bare specifier names (`@scope/name` or the first path
|
|
265
|
+
* segment), or null for relative/absolute paths, `node:`/URL-ish schemes,
|
|
266
|
+
* Node builtins (incl. `fs/promises`), aliases like `@/x` / `~/x` / `#x`, and
|
|
267
|
+
* anything templated.
|
|
268
|
+
*/
|
|
269
|
+
export function npmPackageName(spec) {
|
|
270
|
+
if (!spec || /[\s{}$]/.test(spec))
|
|
271
|
+
return null;
|
|
272
|
+
if (/^[./#~]/.test(spec) || spec.includes(":"))
|
|
273
|
+
return null;
|
|
274
|
+
const parts = spec.split("/");
|
|
275
|
+
const first = parts[0] ?? "";
|
|
276
|
+
let name;
|
|
277
|
+
if (first.startsWith("@")) {
|
|
278
|
+
const second = parts[1];
|
|
279
|
+
if (first === "@" || !second)
|
|
280
|
+
return null;
|
|
281
|
+
name = `${first}/${second}`;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
if (NODE_BUILTINS.has(first))
|
|
285
|
+
return null;
|
|
286
|
+
name = first;
|
|
287
|
+
}
|
|
288
|
+
return NPM_NAME_RE.test(name) ? name : null;
|
|
289
|
+
}
|
|
290
|
+
const PY_STDLIB = new Set([
|
|
291
|
+
"__future__",
|
|
292
|
+
"abc",
|
|
293
|
+
"argparse",
|
|
294
|
+
"array",
|
|
295
|
+
"ast",
|
|
296
|
+
"asyncio",
|
|
297
|
+
"atexit",
|
|
298
|
+
"base64",
|
|
299
|
+
"binascii",
|
|
300
|
+
"bisect",
|
|
301
|
+
"builtins",
|
|
302
|
+
"bz2",
|
|
303
|
+
"calendar",
|
|
304
|
+
"cmath",
|
|
305
|
+
"codecs",
|
|
306
|
+
"collections",
|
|
307
|
+
"colorsys",
|
|
308
|
+
"compileall",
|
|
309
|
+
"concurrent",
|
|
310
|
+
"configparser",
|
|
311
|
+
"contextlib",
|
|
312
|
+
"contextvars",
|
|
313
|
+
"copy",
|
|
314
|
+
"copyreg",
|
|
315
|
+
"cProfile",
|
|
316
|
+
"csv",
|
|
317
|
+
"ctypes",
|
|
318
|
+
"curses",
|
|
319
|
+
"dataclasses",
|
|
320
|
+
"datetime",
|
|
321
|
+
"dbm",
|
|
322
|
+
"decimal",
|
|
323
|
+
"difflib",
|
|
324
|
+
"dis",
|
|
325
|
+
"doctest",
|
|
326
|
+
"email",
|
|
327
|
+
"encodings",
|
|
328
|
+
"enum",
|
|
329
|
+
"errno",
|
|
330
|
+
"faulthandler",
|
|
331
|
+
"fcntl",
|
|
332
|
+
"filecmp",
|
|
333
|
+
"fileinput",
|
|
334
|
+
"fnmatch",
|
|
335
|
+
"fractions",
|
|
336
|
+
"ftplib",
|
|
337
|
+
"functools",
|
|
338
|
+
"gc",
|
|
339
|
+
"getopt",
|
|
340
|
+
"getpass",
|
|
341
|
+
"gettext",
|
|
342
|
+
"glob",
|
|
343
|
+
"graphlib",
|
|
344
|
+
"grp",
|
|
345
|
+
"gzip",
|
|
346
|
+
"hashlib",
|
|
347
|
+
"heapq",
|
|
348
|
+
"hmac",
|
|
349
|
+
"html",
|
|
350
|
+
"http",
|
|
351
|
+
"imaplib",
|
|
352
|
+
"importlib",
|
|
353
|
+
"inspect",
|
|
354
|
+
"io",
|
|
355
|
+
"ipaddress",
|
|
356
|
+
"itertools",
|
|
357
|
+
"json",
|
|
358
|
+
"keyword",
|
|
359
|
+
"linecache",
|
|
360
|
+
"locale",
|
|
361
|
+
"logging",
|
|
362
|
+
"lzma",
|
|
363
|
+
"mailbox",
|
|
364
|
+
"marshal",
|
|
365
|
+
"math",
|
|
366
|
+
"mimetypes",
|
|
367
|
+
"mmap",
|
|
368
|
+
"multiprocessing",
|
|
369
|
+
"numbers",
|
|
370
|
+
"operator",
|
|
371
|
+
"optparse",
|
|
372
|
+
"os",
|
|
373
|
+
"pathlib",
|
|
374
|
+
"pdb",
|
|
375
|
+
"pickle",
|
|
376
|
+
"pkgutil",
|
|
377
|
+
"platform",
|
|
378
|
+
"plistlib",
|
|
379
|
+
"poplib",
|
|
380
|
+
"posix",
|
|
381
|
+
"pprint",
|
|
382
|
+
"profile",
|
|
383
|
+
"pstats",
|
|
384
|
+
"pty",
|
|
385
|
+
"pwd",
|
|
386
|
+
"py_compile",
|
|
387
|
+
"pydoc",
|
|
388
|
+
"queue",
|
|
389
|
+
"quopri",
|
|
390
|
+
"random",
|
|
391
|
+
"re",
|
|
392
|
+
"reprlib",
|
|
393
|
+
"resource",
|
|
394
|
+
"runpy",
|
|
395
|
+
"sched",
|
|
396
|
+
"secrets",
|
|
397
|
+
"select",
|
|
398
|
+
"selectors",
|
|
399
|
+
"shelve",
|
|
400
|
+
"shlex",
|
|
401
|
+
"shutil",
|
|
402
|
+
"signal",
|
|
403
|
+
"site",
|
|
404
|
+
"smtplib",
|
|
405
|
+
"socket",
|
|
406
|
+
"socketserver",
|
|
407
|
+
"sqlite3",
|
|
408
|
+
"ssl",
|
|
409
|
+
"stat",
|
|
410
|
+
"statistics",
|
|
411
|
+
"string",
|
|
412
|
+
"stringprep",
|
|
413
|
+
"struct",
|
|
414
|
+
"subprocess",
|
|
415
|
+
"symtable",
|
|
416
|
+
"sys",
|
|
417
|
+
"sysconfig",
|
|
418
|
+
"syslog",
|
|
419
|
+
"tarfile",
|
|
420
|
+
"tempfile",
|
|
421
|
+
"termios",
|
|
422
|
+
"textwrap",
|
|
423
|
+
"threading",
|
|
424
|
+
"time",
|
|
425
|
+
"timeit",
|
|
426
|
+
"tkinter",
|
|
427
|
+
"token",
|
|
428
|
+
"tokenize",
|
|
429
|
+
"tomllib",
|
|
430
|
+
"trace",
|
|
431
|
+
"traceback",
|
|
432
|
+
"tracemalloc",
|
|
433
|
+
"tty",
|
|
434
|
+
"types",
|
|
435
|
+
"typing",
|
|
436
|
+
"unicodedata",
|
|
437
|
+
"unittest",
|
|
438
|
+
"urllib",
|
|
439
|
+
"uuid",
|
|
440
|
+
"venv",
|
|
441
|
+
"warnings",
|
|
442
|
+
"wave",
|
|
443
|
+
"weakref",
|
|
444
|
+
"webbrowser",
|
|
445
|
+
"wsgiref",
|
|
446
|
+
"xml",
|
|
447
|
+
"xmlrpc",
|
|
448
|
+
"zipapp",
|
|
449
|
+
"zipfile",
|
|
450
|
+
"zipimport",
|
|
451
|
+
"zlib",
|
|
452
|
+
"zoneinfo",
|
|
453
|
+
]);
|
|
454
|
+
/**
|
|
455
|
+
* Import name → distribution name, for the well-known packages whose import
|
|
456
|
+
* name tells you nothing about what to download. Keys are exact import paths
|
|
457
|
+
* (case matters: `PIL`, `Crypto`).
|
|
458
|
+
*/
|
|
459
|
+
const PY_ALIASES = {
|
|
460
|
+
yaml: "pyyaml",
|
|
461
|
+
bs4: "beautifulsoup4",
|
|
462
|
+
PIL: "pillow",
|
|
463
|
+
cv2: "opencv-python",
|
|
464
|
+
sklearn: "scikit-learn",
|
|
465
|
+
dateutil: "python-dateutil",
|
|
466
|
+
jwt: "pyjwt",
|
|
467
|
+
"google.protobuf": "protobuf",
|
|
468
|
+
attr: "attrs",
|
|
469
|
+
dotenv: "python-dotenv",
|
|
470
|
+
magic: "python-magic",
|
|
471
|
+
docx: "python-docx",
|
|
472
|
+
pptx: "python-pptx",
|
|
473
|
+
Crypto: "pycryptodome",
|
|
474
|
+
OpenSSL: "pyopenssl",
|
|
475
|
+
git: "gitpython",
|
|
476
|
+
jose: "python-jose",
|
|
477
|
+
multipart: "python-multipart",
|
|
478
|
+
slugify: "python-slugify",
|
|
479
|
+
};
|
|
480
|
+
const PY_MODULE_RE = /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/;
|
|
481
|
+
function stripPyComment(line) {
|
|
482
|
+
const i = line.indexOf("#");
|
|
483
|
+
return i < 0 ? line : line.slice(0, i);
|
|
484
|
+
}
|
|
485
|
+
/** `a.b as c` → `a.b`. */
|
|
486
|
+
function stripPyAlias(part) {
|
|
487
|
+
return (part.trim().split(/\s+as\s+/)[0] ?? "").trim();
|
|
488
|
+
}
|
|
489
|
+
/** Absolute imports of a Python source; relative (`from .`) imports are skipped. */
|
|
490
|
+
export function extractPythonImports(src) {
|
|
491
|
+
const lines = src.replace(/\\\r?\n/g, " ").split(/\r?\n/);
|
|
492
|
+
const out = [];
|
|
493
|
+
for (let i = 0; i < lines.length; i++) {
|
|
494
|
+
for (const stmt of stripPyComment(lines[i] ?? "").split(";")) {
|
|
495
|
+
const imp = /^\s*import\s+(.+)$/.exec(stmt);
|
|
496
|
+
if (imp) {
|
|
497
|
+
for (const part of (imp[1] ?? "").split(",")) {
|
|
498
|
+
const mod = stripPyAlias(part);
|
|
499
|
+
if (PY_MODULE_RE.test(mod))
|
|
500
|
+
out.push({ module: mod, names: [] });
|
|
501
|
+
}
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const from = /^\s*from\s+([A-Za-z_][\w.]*)\s+import\s+(.*)$/.exec(stmt);
|
|
505
|
+
if (!from)
|
|
506
|
+
continue;
|
|
507
|
+
let rest = from[2] ?? "";
|
|
508
|
+
// `from x import (\n a,\n b\n)` — gather the parenthesised continuation.
|
|
509
|
+
if (rest.includes("(") && !rest.includes(")")) {
|
|
510
|
+
while (++i < lines.length) {
|
|
511
|
+
const l = stripPyComment(lines[i] ?? "");
|
|
512
|
+
rest += ` ${l}`;
|
|
513
|
+
if (l.includes(")"))
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const names = rest
|
|
518
|
+
.replace(/[()]/g, " ")
|
|
519
|
+
.split(",")
|
|
520
|
+
.map(stripPyAlias)
|
|
521
|
+
.filter((n) => /^[A-Za-z_]\w*$/.test(n));
|
|
522
|
+
const module = from[1] ?? "";
|
|
523
|
+
if (PY_MODULE_RE.test(module))
|
|
524
|
+
out.push({ module, names });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
529
|
+
function emptyLock() {
|
|
530
|
+
return { entries: new Map(), links: new Set(), scopedLinks: new Map() };
|
|
531
|
+
}
|
|
532
|
+
/** Record a link: global at the lock's root, else held by `scope`. */
|
|
533
|
+
function addLink(lock, name, scope) {
|
|
534
|
+
if (!scope || scope === ".") {
|
|
535
|
+
lock.links.add(name);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const scopes = lock.scopedLinks.get(name) ?? [];
|
|
539
|
+
if (!scopes.includes(scope))
|
|
540
|
+
scopes.push(scope);
|
|
541
|
+
lock.scopedLinks.set(name, scopes);
|
|
542
|
+
}
|
|
543
|
+
/** Does `name` resolve to a local package for a file at `relInLockDir`? */
|
|
544
|
+
function isLinked(lock, name, relInLockDir) {
|
|
545
|
+
const covers = (scope) => relInLockDir === scope || relInLockDir.startsWith(`${scope}/`);
|
|
546
|
+
// Node resolves from the NEAREST node_modules: a link is the answer only
|
|
547
|
+
// when no installed copy sits in a deeper workspace that also holds the file.
|
|
548
|
+
const linkDepth = Math.max(lock.links.has(name) ? 0 : -1, ...(lock.scopedLinks.get(name) ?? []).filter(covers).map((scope) => scope.length));
|
|
549
|
+
if (linkDepth < 0)
|
|
550
|
+
return false;
|
|
551
|
+
const copyDepth = Math.max(-1, ...(lock.entries.get(name) ?? [])
|
|
552
|
+
.filter((e) => e.scope && covers(e.scope))
|
|
553
|
+
.map((e) => e.scope?.length ?? 0));
|
|
554
|
+
return linkDepth >= copyDepth;
|
|
555
|
+
}
|
|
556
|
+
/** Per lock: `(name, version, scope, foreign)` → its entry plus the spec set,
|
|
557
|
+
* so each insertion is O(1). Foreign is part of the key: a private-registry
|
|
558
|
+
* copy at a public copy's version is a different artifact, and merging the two
|
|
559
|
+
* would drop its mark. A lockfile is PR-controlled and parsed on the event loop
|
|
560
|
+
* before any deadline exists; a linear find per entry made one package with
|
|
561
|
+
* 100k versions a daemon stall. */
|
|
562
|
+
const lockIndex = new WeakMap();
|
|
563
|
+
function addLock(lock, name, entry) {
|
|
564
|
+
let index = lockIndex.get(lock);
|
|
565
|
+
if (!index)
|
|
566
|
+
lockIndex.set(lock, (index = new Map()));
|
|
567
|
+
const key = `${name}\0${entry.version}\0${entry.scope ?? ""}\0${entry.scope === undefined ? 0 : 1}\0${entry.foreign ? 1 : 0}`;
|
|
568
|
+
const have = index.get(key);
|
|
569
|
+
if (!have) {
|
|
570
|
+
const list = lock.entries.get(name) ?? [];
|
|
571
|
+
if (entry.specs)
|
|
572
|
+
entry.specs = [...new Set(entry.specs)];
|
|
573
|
+
list.push(entry);
|
|
574
|
+
lock.entries.set(name, list);
|
|
575
|
+
index.set(key, { entry, specs: new Set(entry.specs ?? []) });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
for (const s of entry.specs ?? []) {
|
|
579
|
+
if (have.specs.has(s))
|
|
580
|
+
continue;
|
|
581
|
+
have.specs.add(s);
|
|
582
|
+
(have.entry.specs ??= []).push(s);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const VERSION_LIKE = /^\d+\.\d+\.\d+/;
|
|
586
|
+
/** The value the resolver hands on as the range of an npm package locked to a
|
|
587
|
+
* non-npmjs source: spelled like the Python foreign pin, so it is a
|
|
588
|
+
* non-registry range and preparation SKIPS it. */
|
|
589
|
+
const NPM_FOREIGN_PIN = "@ non-npmjs source";
|
|
590
|
+
/** A package-lock `resolved` that is not the public registry. Absent means
|
|
591
|
+
* the registry (npm omits it in some lock shapes). */
|
|
592
|
+
function isForeignResolved(resolved) {
|
|
593
|
+
if (typeof resolved !== "string" || resolved === "")
|
|
594
|
+
return false;
|
|
595
|
+
try {
|
|
596
|
+
const u = new URL(resolved);
|
|
597
|
+
return !(u.protocol === "https:" &&
|
|
598
|
+
(u.hostname === "registry.npmjs.org" || u.hostname === "registry.yarnpkg.com"));
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** package-lock.json / npm-shrinkwrap.json, lockfileVersion 1, 2 and 3. */
|
|
605
|
+
export function parsePackageLock(text, implicitForeign = () => false) {
|
|
606
|
+
const foreignSource = (name, resolved) => typeof resolved === "string" && resolved !== ""
|
|
607
|
+
? isForeignResolved(resolved)
|
|
608
|
+
: implicitForeign(name);
|
|
609
|
+
let doc;
|
|
610
|
+
try {
|
|
611
|
+
doc = JSON.parse(text);
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
if (!isObj(doc))
|
|
617
|
+
return null;
|
|
618
|
+
const lock = emptyLock();
|
|
619
|
+
if (isObj(doc.packages)) {
|
|
620
|
+
for (const [key, val] of Object.entries(doc.packages)) {
|
|
621
|
+
if (!key || !isObj(val))
|
|
622
|
+
continue;
|
|
623
|
+
const idx = key.lastIndexOf("node_modules/");
|
|
624
|
+
if (idx < 0) {
|
|
625
|
+
// A workspace package's own entry ("packages/web": {name, version}).
|
|
626
|
+
const n = str(val.name);
|
|
627
|
+
if (n)
|
|
628
|
+
lock.links.add(n);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const name = key.slice(idx + "node_modules/".length);
|
|
632
|
+
const prefix = key.slice(0, idx);
|
|
633
|
+
if (val.link === true) {
|
|
634
|
+
// A workspace's own nested link holds for that workspace's files only.
|
|
635
|
+
if (!prefix.includes("node_modules/"))
|
|
636
|
+
addLink(lock, name, prefix.replace(/\/$/, ""));
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const version = str(val.version);
|
|
640
|
+
if (!version || !VERSION_LIKE.test(version))
|
|
641
|
+
continue;
|
|
642
|
+
const foreign = foreignSource(name, val.resolved) ? { foreign: true } : {};
|
|
643
|
+
if (prefix === "")
|
|
644
|
+
addLock(lock, name, { version, ...foreign });
|
|
645
|
+
// Nested under a WORKSPACE (not under another package): that
|
|
646
|
+
// workspace's own copy, which beats the hoisted one for its files.
|
|
647
|
+
else if (!prefix.includes("node_modules/"))
|
|
648
|
+
addLock(lock, name, { version, scope: prefix.replace(/\/$/, ""), ...foreign });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
else if (isObj(doc.dependencies)) {
|
|
652
|
+
for (const [name, val] of Object.entries(doc.dependencies)) {
|
|
653
|
+
if (!isObj(val))
|
|
654
|
+
continue;
|
|
655
|
+
const version = str(val.version);
|
|
656
|
+
if (!version)
|
|
657
|
+
continue;
|
|
658
|
+
if (/^(file|link):/.test(version))
|
|
659
|
+
lock.links.add(name);
|
|
660
|
+
else if (VERSION_LIKE.test(version))
|
|
661
|
+
addLock(lock, name, foreignSource(name, val.resolved) ? { version, foreign: true } : { version });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return lock;
|
|
665
|
+
}
|
|
666
|
+
/** Split a yarn/berry header spec (`@scope/x@^1`, `x@npm:^1`) into name and range. */
|
|
667
|
+
function splitYarnSpec(spec) {
|
|
668
|
+
const at = spec.indexOf("@", 1);
|
|
669
|
+
if (at <= 0)
|
|
670
|
+
return null;
|
|
671
|
+
return { name: spec.slice(0, at), range: spec.slice(at + 1) };
|
|
672
|
+
}
|
|
673
|
+
/** Record one yarn.lock block: its header specs locked at `version`, foreign
|
|
674
|
+
* when its `resolved` URL is off the public registry. */
|
|
675
|
+
function addYarnBlock(lock, specs, version, resolved) {
|
|
676
|
+
const ranges = new Map();
|
|
677
|
+
for (const spec of specs) {
|
|
678
|
+
const s = splitYarnSpec(spec);
|
|
679
|
+
if (!s)
|
|
680
|
+
continue;
|
|
681
|
+
const range = s.range.startsWith("npm:") ? s.range.slice(4) : s.range;
|
|
682
|
+
if (isLocalRange(range)) {
|
|
683
|
+
lock.links.add(s.name);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
ranges.set(s.name, [...(ranges.get(s.name) ?? []), range]);
|
|
687
|
+
}
|
|
688
|
+
if (!VERSION_LIKE.test(version))
|
|
689
|
+
return;
|
|
690
|
+
const foreign = isForeignResolved(resolved) ? { foreign: true } : {};
|
|
691
|
+
for (const [name, rs] of ranges)
|
|
692
|
+
addLock(lock, name, { version, specs: rs, ...foreign });
|
|
693
|
+
}
|
|
694
|
+
/** yarn.lock — classic v1 (`version "1.2.3"`) and berry (`version: 1.2.3`).
|
|
695
|
+
* A classic entry's `resolved` URL off the public registry marks it foreign
|
|
696
|
+
* (as in package-lock). Berry carries no registry URL — its registry lives in
|
|
697
|
+
* .yarnrc — so a berry entry cannot be judged that way. */
|
|
698
|
+
export function parseYarnLock(text) {
|
|
699
|
+
const lock = emptyLock();
|
|
700
|
+
let specs = null;
|
|
701
|
+
let version = null;
|
|
702
|
+
let resolved = null;
|
|
703
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
704
|
+
if (!raw.trim() || raw.trimStart().startsWith("#"))
|
|
705
|
+
continue;
|
|
706
|
+
if (!/^\s/.test(raw)) {
|
|
707
|
+
if (specs && version !== null)
|
|
708
|
+
addYarnBlock(lock, specs, version, resolved);
|
|
709
|
+
version = resolved = null;
|
|
710
|
+
specs = raw.endsWith(":")
|
|
711
|
+
? raw
|
|
712
|
+
.slice(0, -1)
|
|
713
|
+
.replace(/["']/g, "")
|
|
714
|
+
.split(",")
|
|
715
|
+
.map((s) => s.trim())
|
|
716
|
+
.filter(Boolean)
|
|
717
|
+
: null;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (!specs)
|
|
721
|
+
continue;
|
|
722
|
+
if (version === null) {
|
|
723
|
+
const m = /^ {2}version:?\s+"?([^"\s]+)"?\s*$/.exec(raw);
|
|
724
|
+
if (m) {
|
|
725
|
+
version = m[1] ?? "";
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (resolved === null) {
|
|
730
|
+
const r = /^ {2}resolved\s+"?([^"\s]+)"?\s*$/.exec(raw);
|
|
731
|
+
if (r)
|
|
732
|
+
resolved = r[1] ?? "";
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (specs && version !== null)
|
|
736
|
+
addYarnBlock(lock, specs, version, resolved);
|
|
737
|
+
return lock;
|
|
738
|
+
}
|
|
739
|
+
/** One pnpm package key → name + version (v5 `/x/1.2.3_peer`, v6 `/x@1.2.3(peer)`, v9 `x@1.2.3`). */
|
|
740
|
+
function splitPnpmKey(key) {
|
|
741
|
+
const v5 = /^(@[^/]+\/[^/@]+|[^/@]+)\/(\d[^/_(]*)(?:_.*)?$/.exec(key);
|
|
742
|
+
if (v5)
|
|
743
|
+
return { name: v5[1] ?? "", version: v5[2] ?? "" };
|
|
744
|
+
const at = key.lastIndexOf("@");
|
|
745
|
+
if (at <= 0)
|
|
746
|
+
return null;
|
|
747
|
+
return { name: key.slice(0, at), version: key.slice(at + 1) };
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* pnpm-lock.yaml — the `packages:` (and v9 `snapshots:`) keys are the whole
|
|
751
|
+
* inventory, but the version a DIRECT dependency resolves to is the importer's
|
|
752
|
+
* own pin (`importers:` in v6+/workspaces, the top-level `dependencies:` of a
|
|
753
|
+
* v5 single project). Those pins are recorded with their specifier (and the
|
|
754
|
+
* importer dir as scope), so pickLocked takes the importer's resolution rather
|
|
755
|
+
* than the newest copy some transitive dependency pulled in. A package whose
|
|
756
|
+
* `resolution` names a tarball off the public registry is foreign, and so is
|
|
757
|
+
* every importer pin at its name and version.
|
|
758
|
+
*/
|
|
759
|
+
export function parsePnpmLock(text, defaultForeign = false) {
|
|
760
|
+
const lock = emptyLock();
|
|
761
|
+
let section = "other";
|
|
762
|
+
let rootGroup = "deps";
|
|
763
|
+
let importer = ".";
|
|
764
|
+
let group = null;
|
|
765
|
+
let dep = null;
|
|
766
|
+
// importer → name → { spec, version }
|
|
767
|
+
const pins = new Map();
|
|
768
|
+
const pin = (imp, name) => {
|
|
769
|
+
const m = pins.get(imp) ?? new Map();
|
|
770
|
+
pins.set(imp, m);
|
|
771
|
+
const p = m.get(name) ?? {};
|
|
772
|
+
m.set(name, p);
|
|
773
|
+
return p;
|
|
774
|
+
};
|
|
775
|
+
const unquote = (x) => x.trim().replace(/^['"]|['"]$/g, "");
|
|
776
|
+
/** A dependency's own line: an inline value (v5: the version, or under
|
|
777
|
+
* `specifiers` the spec) is recorded; a bare `name:` opens its v6 block. */
|
|
778
|
+
const depHead = (imp, name, inline, specs) => {
|
|
779
|
+
if (!inline)
|
|
780
|
+
return name;
|
|
781
|
+
const p = pin(imp, name);
|
|
782
|
+
if (specs)
|
|
783
|
+
p.spec = inline;
|
|
784
|
+
else
|
|
785
|
+
p.version = inline;
|
|
786
|
+
return null;
|
|
787
|
+
};
|
|
788
|
+
/** A `specifier:` / `version:` line inside a dependency's v6 block. */
|
|
789
|
+
const depField = (imp, name, field, value) => {
|
|
790
|
+
const p = pin(imp, name);
|
|
791
|
+
if (field === "specifier")
|
|
792
|
+
p.spec = unquote(value);
|
|
793
|
+
else
|
|
794
|
+
p.version = unquote(value);
|
|
795
|
+
};
|
|
796
|
+
/** The `packages:` block being read, and the name@version of every one whose
|
|
797
|
+
* resolution is a tarball off the public registry. */
|
|
798
|
+
let pkg = null;
|
|
799
|
+
// Peer variants collapse to name@version below. Any explicit foreign variant
|
|
800
|
+
// makes that collapsed identity unsafe, regardless of package order.
|
|
801
|
+
const sourceByPackage = new Map();
|
|
802
|
+
const packages = [];
|
|
803
|
+
const foreignSource = (name, version) => {
|
|
804
|
+
const source = sourceByPackage.get(`${name}@${version}`);
|
|
805
|
+
return (source?.foreign === true ||
|
|
806
|
+
((typeof defaultForeign === "function" ? defaultForeign(name) : defaultForeign) &&
|
|
807
|
+
source?.public !== true));
|
|
808
|
+
};
|
|
809
|
+
const flushPkg = () => {
|
|
810
|
+
if (!pkg)
|
|
811
|
+
return;
|
|
812
|
+
packages.push({ name: pkg.name, version: pkg.version });
|
|
813
|
+
const key = `${pkg.name}@${pkg.version}`;
|
|
814
|
+
const source = sourceByPackage.get(key) ?? { foreign: false, public: false };
|
|
815
|
+
if (pkg.foreign !== undefined) {
|
|
816
|
+
source.foreign ||= pkg.foreign;
|
|
817
|
+
source.public ||= !pkg.foreign;
|
|
818
|
+
}
|
|
819
|
+
sourceByPackage.set(key, source);
|
|
820
|
+
pkg = null;
|
|
821
|
+
};
|
|
822
|
+
const DEP_GROUP = /^(dependencies|devDependencies|optionalDependencies|specifiers):\s*$/;
|
|
823
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
824
|
+
if (/^\S/.test(raw)) {
|
|
825
|
+
flushPkg();
|
|
826
|
+
const g = DEP_GROUP.exec(raw);
|
|
827
|
+
section = /^(packages|snapshots):\s*$/.test(raw)
|
|
828
|
+
? "packages"
|
|
829
|
+
: /^importers:\s*$/.test(raw)
|
|
830
|
+
? "importers"
|
|
831
|
+
: g
|
|
832
|
+
? "root"
|
|
833
|
+
: "other";
|
|
834
|
+
if (g)
|
|
835
|
+
rootGroup = g[1] === "specifiers" ? "specs" : "deps";
|
|
836
|
+
dep = null;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (section === "packages") {
|
|
840
|
+
// ` key:` opens a package; its deeper lines are its fields.
|
|
841
|
+
const m = /^ {2}(\S.*?):\s*$/.exec(raw);
|
|
842
|
+
if (!m) {
|
|
843
|
+
// `resolution: {…, tarball: <url>}`, inline or as its own line.
|
|
844
|
+
const tarball = /\btarball:\s*['"]?([^,'"}\s]+)/.exec(raw);
|
|
845
|
+
if (pkg && tarball && /^ {4}/.test(raw))
|
|
846
|
+
pkg.foreign = isForeignResolved(tarball[1]);
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
flushPkg();
|
|
850
|
+
let key = unquote(m[1] ?? "");
|
|
851
|
+
if (key.startsWith("/"))
|
|
852
|
+
key = key.slice(1);
|
|
853
|
+
const paren = key.indexOf("(");
|
|
854
|
+
if (paren > 0)
|
|
855
|
+
key = key.slice(0, paren);
|
|
856
|
+
const nv = splitPnpmKey(key);
|
|
857
|
+
if (nv && nv.name && VERSION_LIKE.test(nv.version))
|
|
858
|
+
pkg = {
|
|
859
|
+
name: nv.name,
|
|
860
|
+
version: nv.version,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
else if (section === "root") {
|
|
864
|
+
// v5 single project: ` name: value` directly under the section;
|
|
865
|
+
// v6 single project: ` name:` then ` specifier:` / ` version:`.
|
|
866
|
+
const head = /^ {2}(\S.*?):\s*(.*?)\s*$/.exec(raw);
|
|
867
|
+
if (head) {
|
|
868
|
+
dep = depHead(".", unquote(head[1] ?? ""), unquote(head[2] ?? ""), rootGroup === "specs");
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const kv = /^ {4}(specifier|version):\s*(.*?)\s*$/.exec(raw);
|
|
872
|
+
if (kv && dep && rootGroup === "deps")
|
|
873
|
+
depField(".", dep, kv[1] ?? "", kv[2] ?? "");
|
|
874
|
+
}
|
|
875
|
+
else if (section === "importers") {
|
|
876
|
+
const imp = /^ {2}(\S.*?):\s*$/.exec(raw);
|
|
877
|
+
if (imp) {
|
|
878
|
+
importer = unquote(imp[1] ?? ".");
|
|
879
|
+
group = null;
|
|
880
|
+
dep = null;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const g = /^ {4}(\S+):\s*$/.exec(raw);
|
|
884
|
+
if (g) {
|
|
885
|
+
const k = DEP_GROUP.exec(g[1] + ":");
|
|
886
|
+
group = k ? (k[1] === "specifiers" ? "specs" : "deps") : null;
|
|
887
|
+
dep = null;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (!group)
|
|
891
|
+
continue;
|
|
892
|
+
// v5 workspace form: ` name: 1.2.3` / specifiers ` name: ^1`.
|
|
893
|
+
const d = /^ {6}(\S.*?):\s*(.*?)\s*$/.exec(raw);
|
|
894
|
+
if (d) {
|
|
895
|
+
dep = depHead(importer, unquote(d[1] ?? ""), unquote(d[2] ?? ""), group === "specs");
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
const kv = /^ {8}(specifier|version):\s*(.*?)\s*$/.exec(raw);
|
|
899
|
+
if (kv && dep && group === "deps")
|
|
900
|
+
depField(importer, dep, kv[1] ?? "", kv[2] ?? "");
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
flushPkg();
|
|
904
|
+
for (const { name, version } of packages)
|
|
905
|
+
addLock(lock, name, { version, ...(foreignSource(name, version) ? { foreign: true } : {}) });
|
|
906
|
+
for (const [imp, deps] of pins) {
|
|
907
|
+
for (const [name, p] of deps) {
|
|
908
|
+
// `1.2.0(react@18)` (v6+) / `1.2.0_react@18` (v5): the peer suffix is not the version.
|
|
909
|
+
const scope = imp.replace(/^\.\//, "").replace(/\/$/, "");
|
|
910
|
+
// A semver specifier pnpm resolved to a workspace path (`link:../shared`)
|
|
911
|
+
// is that importer's local package, not the registry's same-named one.
|
|
912
|
+
if (isLocalRange(p.version ?? "")) {
|
|
913
|
+
addLink(lock, name, scope);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
const version = (p.version ?? "").replace(/[(_].*$/, "");
|
|
917
|
+
if (!VERSION_LIKE.test(version))
|
|
918
|
+
continue;
|
|
919
|
+
const entry = { version };
|
|
920
|
+
if (p.spec)
|
|
921
|
+
entry.specs = [p.spec];
|
|
922
|
+
if (foreignSource(name, version))
|
|
923
|
+
entry.foreign = true;
|
|
924
|
+
if (scope && scope !== ".")
|
|
925
|
+
entry.scope = scope;
|
|
926
|
+
addLock(lock, name, entry);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return lock;
|
|
930
|
+
}
|
|
931
|
+
/** The value parsePythonLock records for a package locked to a source other
|
|
932
|
+
* than public PyPI: `@ <kind> source` — spelled as a PEP 508 direct reference,
|
|
933
|
+
* so the resolver hands it on as a non-registry range and preparation SKIPS
|
|
934
|
+
* it, instead of fetching PyPI's same-named release and calling it the lock. */
|
|
935
|
+
const PY_FOREIGN_PIN_PREFIX = "@ ";
|
|
936
|
+
/**
|
|
937
|
+
* uv.lock / poetry.lock: `[[package]]` blocks with `name` and `version`. A
|
|
938
|
+
* line scanner, not a TOML parser — only the block's own top-level keys are
|
|
939
|
+
* read, and local (editable / virtual / directory) packages are first-party:
|
|
940
|
+
* never in the returned pins, and named in `locals` when the caller passes
|
|
941
|
+
* one, so a workspace member the import scan cannot find on disk is still
|
|
942
|
+
* never fetched from PyPI. A package from git, a URL or a non-PyPI index maps
|
|
943
|
+
* to `@ <kind> source` (PY_FOREIGN_PIN_PREFIX), never to its bare version.
|
|
944
|
+
*/
|
|
945
|
+
export function parsePythonLock(text, locals, defaultForeign = false, publicDefaults) {
|
|
946
|
+
const out = new Map();
|
|
947
|
+
let cur = null;
|
|
948
|
+
let where = "other";
|
|
949
|
+
const flush = () => {
|
|
950
|
+
if (cur?.name && cur.local)
|
|
951
|
+
locals?.add(normPy(cur.name));
|
|
952
|
+
if (cur?.name && cur.version && !cur.local) {
|
|
953
|
+
const n = normPy(cur.name);
|
|
954
|
+
if (!out.has(n))
|
|
955
|
+
out.set(n, cur.foreign || (defaultForeign && !cur.publicSource && !publicDefaults?.has(n))
|
|
956
|
+
? `${PY_FOREIGN_PIN_PREFIX}${cur.foreign ?? "index"} source`
|
|
957
|
+
: cur.version);
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
961
|
+
const line = raw.trim();
|
|
962
|
+
if (line === "[[package]]") {
|
|
963
|
+
flush();
|
|
964
|
+
cur = {};
|
|
965
|
+
where = "top";
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
if (line.startsWith("[")) {
|
|
969
|
+
where = line === "[package.source]" ? "source" : "other";
|
|
970
|
+
if (!/^\[\[?package[.\]]/.test(line)) {
|
|
971
|
+
flush();
|
|
972
|
+
cur = null;
|
|
973
|
+
}
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
if (!cur)
|
|
977
|
+
continue;
|
|
978
|
+
if (where === "top") {
|
|
979
|
+
const kv = /^(name|version)\s*=\s*"([^"]*)"/.exec(line);
|
|
980
|
+
if (kv)
|
|
981
|
+
cur[kv[1]] = kv[2] ?? "";
|
|
982
|
+
if (/^source\s*=\s*\{[^}]*\b(editable|virtual|directory|path)\s*=/.test(line))
|
|
983
|
+
cur.local = true;
|
|
984
|
+
else {
|
|
985
|
+
// uv: `source = { registry = "…" }` is PyPI only at public PyPI's
|
|
986
|
+
// index; git / url / any other index is a different artifact under
|
|
987
|
+
// the same name.
|
|
988
|
+
const src = /^source\s*=\s*\{\s*(\w+)\s*=\s*"([^"]*)"/.exec(line);
|
|
989
|
+
if (src) {
|
|
990
|
+
cur.publicSource = src[1] === "registry" && isPublicPypiIndex(src[2] ?? "");
|
|
991
|
+
if (!cur.publicSource)
|
|
992
|
+
cur.foreign = src[1] ?? "other";
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
else if (where === "source") {
|
|
997
|
+
// poetry: a `[package.source]` block at all means not-PyPI (PyPI
|
|
998
|
+
// packages carry none); directory/file are local, the rest foreign.
|
|
999
|
+
const t = /^type\s*=\s*"([^"]*)"/.exec(line);
|
|
1000
|
+
if (t && /^(directory|file)$/.test(t[1] ?? ""))
|
|
1001
|
+
cur.local = true;
|
|
1002
|
+
else if (t)
|
|
1003
|
+
cur.foreign = t[1] || "other";
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
flush();
|
|
1007
|
+
return out;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Pipfile.lock: `default`/`develop` → `{ name: { version: "==1.2.3" } }`. An
|
|
1011
|
+
* entry's `index` names one of `_meta.sources` (none = the first, pipenv's
|
|
1012
|
+
* default); a package from any index other than public PyPI — or from an
|
|
1013
|
+
* index the lock does not declare — maps to `@ index source`
|
|
1014
|
+
* (PY_FOREIGN_PIN_PREFIX), exactly as parsePythonLock does for uv/poetry.
|
|
1015
|
+
*/
|
|
1016
|
+
export function parsePipfileLock(text) {
|
|
1017
|
+
const out = new Map();
|
|
1018
|
+
let doc;
|
|
1019
|
+
try {
|
|
1020
|
+
doc = JSON.parse(text);
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
return out;
|
|
1024
|
+
}
|
|
1025
|
+
if (!isObj(doc))
|
|
1026
|
+
return out;
|
|
1027
|
+
const indexUrl = new Map();
|
|
1028
|
+
let defaultIndex;
|
|
1029
|
+
const meta = doc._meta;
|
|
1030
|
+
if (isObj(meta) && Array.isArray(meta.sources)) {
|
|
1031
|
+
for (const src of meta.sources) {
|
|
1032
|
+
const n = isObj(src) ? str(src.name) : undefined;
|
|
1033
|
+
if (n === undefined || !isObj(src))
|
|
1034
|
+
continue;
|
|
1035
|
+
defaultIndex ??= n;
|
|
1036
|
+
if (!indexUrl.has(n))
|
|
1037
|
+
indexUrl.set(n, str(src.url) ?? "");
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
const isPypi = (index) => {
|
|
1041
|
+
// No sources declared: pipenv's default source, named "pypi", is PyPI.
|
|
1042
|
+
if (indexUrl.size === 0)
|
|
1043
|
+
return index === undefined || index === "pypi";
|
|
1044
|
+
// pypi.python.org is PyPI's legacy host — older Pipfiles still name it.
|
|
1045
|
+
return isPublicPypiIndex((index === undefined ? undefined : indexUrl.get(index)) ?? "");
|
|
1046
|
+
};
|
|
1047
|
+
for (const section of ["default", "develop"]) {
|
|
1048
|
+
const sec = doc[section];
|
|
1049
|
+
if (!isObj(sec))
|
|
1050
|
+
continue;
|
|
1051
|
+
for (const [name, val] of Object.entries(sec)) {
|
|
1052
|
+
if (!isObj(val))
|
|
1053
|
+
continue;
|
|
1054
|
+
const v = str(val.version);
|
|
1055
|
+
if (!v?.startsWith("=="))
|
|
1056
|
+
continue;
|
|
1057
|
+
const index = str(val.index) ?? defaultIndex;
|
|
1058
|
+
out.set(normPy(name), isPypi(index) ? v.slice(2) : `${PY_FOREIGN_PIN_PREFIX}index source`);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return out;
|
|
1062
|
+
}
|
|
1063
|
+
/** One PEP 508 requirement string → normalised name + spec. */
|
|
1064
|
+
function parsePep508(line) {
|
|
1065
|
+
const s = (line.split(";")[0] ?? "").replace(/\s--hash[=\s]\S+/g, "").trim();
|
|
1066
|
+
const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(.*)$/.exec(s);
|
|
1067
|
+
if (!m)
|
|
1068
|
+
return null;
|
|
1069
|
+
const name = normPy(m[1] ?? "");
|
|
1070
|
+
let spec = (m[2] ?? "").trim();
|
|
1071
|
+
if (spec.startsWith("@"))
|
|
1072
|
+
return { name, pin: null, range: spec };
|
|
1073
|
+
spec = spec.replace(/^\(|\)$/g, "").trim();
|
|
1074
|
+
if (!spec)
|
|
1075
|
+
return { name, pin: null, range: "*" };
|
|
1076
|
+
const pin = /^===?\s*([0-9][0-9A-Za-z.+!_-]*)$/.exec(spec);
|
|
1077
|
+
return { name, pin: pin?.[1] ?? null, range: spec };
|
|
1078
|
+
}
|
|
1079
|
+
/** A requirements-file line without its comment, trimmed. */
|
|
1080
|
+
function requirementLine(raw) {
|
|
1081
|
+
return raw.replace(/(^|\s)#.*$/, "").trim();
|
|
1082
|
+
}
|
|
1083
|
+
/** Public PyPI's simple index — pypi.org, or its legacy host pypi.python.org
|
|
1084
|
+
* that older files still name. Any other index is a different artifact under
|
|
1085
|
+
* the same name. */
|
|
1086
|
+
function isPublicPypiIndex(url) {
|
|
1087
|
+
return /^https:\/\/pypi\.(?:python\.)?org\/simple\/?$/i.test(url);
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* requirements*.txt / *.in: every requirement line (options, `-r`, `-e` and
|
|
1091
|
+
* comments skipped). An index option anywhere in the file binds every
|
|
1092
|
+
* requirement in it, as pip reads it: `--index-url`/`-i` naming anything but
|
|
1093
|
+
* public PyPI, or any `--extra-index-url`, `--find-links`/`-f` or
|
|
1094
|
+
* `--no-index`, makes each one `@ index source` (PY_FOREIGN_PIN_PREFIX) with
|
|
1095
|
+
* no pin — PyPI's same-named release is not what that file installs. A
|
|
1096
|
+
* direct reference keeps its own `@ url`.
|
|
1097
|
+
*/
|
|
1098
|
+
export function parseRequirements(text) {
|
|
1099
|
+
const out = new Map();
|
|
1100
|
+
let foreign = false;
|
|
1101
|
+
for (const raw of text.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
|
|
1102
|
+
const line = requirementLine(raw);
|
|
1103
|
+
if (!line)
|
|
1104
|
+
continue;
|
|
1105
|
+
if (line.startsWith("-")) {
|
|
1106
|
+
const index = /^(?:--index-url|-i)(?:\s*=\s*|\s+)(\S+)/.exec(line);
|
|
1107
|
+
if (index) {
|
|
1108
|
+
if (!isPublicPypiIndex(index[1] ?? ""))
|
|
1109
|
+
foreign = true;
|
|
1110
|
+
}
|
|
1111
|
+
else if (/^(?:--extra-index-url|--find-links|-f|--no-index)(?:[=\s]|$)/.test(line)) {
|
|
1112
|
+
foreign = true;
|
|
1113
|
+
}
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
const r = parsePep508(line);
|
|
1117
|
+
if (r && !out.has(r.name))
|
|
1118
|
+
out.set(r.name, { pin: r.pin, range: r.range });
|
|
1119
|
+
}
|
|
1120
|
+
if (foreign)
|
|
1121
|
+
for (const [k, r] of out)
|
|
1122
|
+
if (!r.range.startsWith("@"))
|
|
1123
|
+
out.set(k, { pin: null, range: `${PY_FOREIGN_PIN_PREFIX}index source` });
|
|
1124
|
+
return out;
|
|
1125
|
+
}
|
|
1126
|
+
/** One TOML string token: basic (`"…"`, escapes) or literal (`'…'`). */
|
|
1127
|
+
const TOML_STRING_RE = /"((?:[^"\\]|\\.)*)"|'([^']*)'/g;
|
|
1128
|
+
function stripTomlStrings(line) {
|
|
1129
|
+
return line.replace(TOML_STRING_RE, "");
|
|
1130
|
+
}
|
|
1131
|
+
function tomlStrings(line) {
|
|
1132
|
+
return [...line.matchAll(TOML_STRING_RE)].map((m) => m[1] ?? m[2] ?? "");
|
|
1133
|
+
}
|
|
1134
|
+
/** The string tokens of a PEP 621 / PEP 735 array line, leaving out inline
|
|
1135
|
+
* tables (`{ include-group = "test" }` names a group, not a requirement). */
|
|
1136
|
+
const TOML_INLINE_TABLE_RE = /\{(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^}"'])*\}/g;
|
|
1137
|
+
function requirementStrings(line) {
|
|
1138
|
+
return tomlStrings(line.replace(TOML_INLINE_TABLE_RE, ""));
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* pyproject.toml dependency declarations: PEP 621 `[project] dependencies`
|
|
1142
|
+
* and `[project.optional-dependencies]`, PEP 735 `[dependency-groups]`,
|
|
1143
|
+
* `[tool.uv] dev-dependencies`, and Poetry's dependency tables. Poetry path
|
|
1144
|
+
* dependencies come back as range `file:<path>` (first-party).
|
|
1145
|
+
*/
|
|
1146
|
+
export function parsePyprojectDeps(text, publicSources) {
|
|
1147
|
+
const out = new Map();
|
|
1148
|
+
const add = (s) => {
|
|
1149
|
+
const r = parsePep508(s);
|
|
1150
|
+
if (r && r.name !== "python" && !out.has(r.name))
|
|
1151
|
+
out.set(r.name, { pin: r.pin, range: r.range });
|
|
1152
|
+
};
|
|
1153
|
+
let table = "";
|
|
1154
|
+
let inArray = false;
|
|
1155
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
1156
|
+
const line = raw.trim();
|
|
1157
|
+
if (!line || line.startsWith("#"))
|
|
1158
|
+
continue;
|
|
1159
|
+
if (inArray) {
|
|
1160
|
+
for (const s of requirementStrings(line))
|
|
1161
|
+
add(s);
|
|
1162
|
+
if (stripTomlStrings(line).includes("]"))
|
|
1163
|
+
inArray = false;
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
const hdr = /^\[\[?([^\]]+)\]\]?\s*(?:#.*)?$/.exec(line);
|
|
1167
|
+
if (hdr) {
|
|
1168
|
+
table = (hdr[1] ?? "").trim().replace(/\s+/g, "");
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
const pep621 = (table === "project" && /^dependencies\s*=/.test(line)) ||
|
|
1172
|
+
((table === "project.optional-dependencies" || table === "dependency-groups") &&
|
|
1173
|
+
/^[\w.-]+\s*=\s*\[/.test(line)) ||
|
|
1174
|
+
(table === "tool.uv" && /^dev-dependencies\s*=/.test(line));
|
|
1175
|
+
if (pep621) {
|
|
1176
|
+
const after = line.slice(line.indexOf("=") + 1);
|
|
1177
|
+
for (const s of requirementStrings(after))
|
|
1178
|
+
add(s);
|
|
1179
|
+
if (!stripTomlStrings(after).includes("]"))
|
|
1180
|
+
inArray = true;
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const poetry = table === "tool.poetry.dependencies" ||
|
|
1184
|
+
table === "tool.poetry.dev-dependencies" ||
|
|
1185
|
+
/^tool\.poetry\.group\.[^.]+\.dependencies$/.test(table);
|
|
1186
|
+
if (!poetry)
|
|
1187
|
+
continue;
|
|
1188
|
+
const kv = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*(.+)$/.exec(line);
|
|
1189
|
+
if (!kv)
|
|
1190
|
+
continue;
|
|
1191
|
+
const name = normPy(kv[1] ?? "");
|
|
1192
|
+
if (name === "python" || out.has(name))
|
|
1193
|
+
continue;
|
|
1194
|
+
const val = (kv[2] ?? "").trim();
|
|
1195
|
+
let range = "*";
|
|
1196
|
+
if (val.startsWith('"') || val.startsWith("'"))
|
|
1197
|
+
range = tomlStrings(val)[0] ?? "*";
|
|
1198
|
+
else if (val.startsWith("{")) {
|
|
1199
|
+
const version = /\bversion\s*=\s*["']([^"']*)["']/.exec(val);
|
|
1200
|
+
const path = /\bpath\s*=\s*["']([^"']*)["']/.exec(val);
|
|
1201
|
+
const other = /\b(git|url)\s*=\s*["']([^"']*)["']/.exec(val);
|
|
1202
|
+
// A named Poetry source other than PyPI: the same name there is a
|
|
1203
|
+
// different artifact from PyPI's release.
|
|
1204
|
+
const source = /\bsource\s*=\s*["']([^"']*)["']/.exec(val);
|
|
1205
|
+
if (path)
|
|
1206
|
+
range = `file:${path[1] ?? ""}`;
|
|
1207
|
+
else if (other)
|
|
1208
|
+
range = `@ ${other[2] ?? ""}`;
|
|
1209
|
+
else if (source && !/^pypi$/i.test(source[1] ?? ""))
|
|
1210
|
+
range = `${PY_FOREIGN_PIN_PREFIX}index source`;
|
|
1211
|
+
else {
|
|
1212
|
+
if (source)
|
|
1213
|
+
publicSources?.add(name);
|
|
1214
|
+
if (version)
|
|
1215
|
+
range = version[1] ?? "*";
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
const pin = /^(?:==)?\s*(\d[0-9A-Za-z.+!_-]*)$/.exec(range);
|
|
1219
|
+
out.set(name, { pin: pin?.[1] ?? null, range });
|
|
1220
|
+
}
|
|
1221
|
+
return out;
|
|
1222
|
+
}
|
|
1223
|
+
const NPM_DEP_SECTIONS = [
|
|
1224
|
+
"dependencies",
|
|
1225
|
+
"devDependencies",
|
|
1226
|
+
"peerDependencies",
|
|
1227
|
+
"optionalDependencies",
|
|
1228
|
+
];
|
|
1229
|
+
function parsePackageJson(text) {
|
|
1230
|
+
let doc;
|
|
1231
|
+
try {
|
|
1232
|
+
doc = JSON.parse(text);
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
if (!isObj(doc))
|
|
1238
|
+
return null;
|
|
1239
|
+
const deps = new Map();
|
|
1240
|
+
for (const section of NPM_DEP_SECTIONS) {
|
|
1241
|
+
const sec = doc[section];
|
|
1242
|
+
if (!isObj(sec))
|
|
1243
|
+
continue;
|
|
1244
|
+
for (const [n, r] of Object.entries(sec))
|
|
1245
|
+
if (typeof r === "string" && !deps.has(n))
|
|
1246
|
+
deps.set(n, r);
|
|
1247
|
+
}
|
|
1248
|
+
const name = str(doc.name);
|
|
1249
|
+
return name ? { name, deps } : { deps };
|
|
1250
|
+
}
|
|
1251
|
+
/** A declared range that points INSIDE the repo — first-party, never fetched. */
|
|
1252
|
+
function isLocalRange(range) {
|
|
1253
|
+
return /^(workspace|link|file|portal):/.test(range);
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* A declared range the registry cannot resolve: git / URL / tarball / GitHub
|
|
1257
|
+
* shorthand / npm alias (`npm:x@1`) / a PEP 508 direct reference (`@ url`).
|
|
1258
|
+
* No semver range or PEP 440 specifier contains any of these characters.
|
|
1259
|
+
*/
|
|
1260
|
+
function isNonRegistryRange(range) {
|
|
1261
|
+
return /[:/@#]/.test(range);
|
|
1262
|
+
}
|
|
1263
|
+
/** An unresolved sighting or request whose declared range names a source the
|
|
1264
|
+
* registry cannot resolve: planning keeps it apart from every registry entry
|
|
1265
|
+
* and preparation skips it, by this one test. */
|
|
1266
|
+
function isUnfetchable(x) {
|
|
1267
|
+
return x.version === null && x.range !== undefined && isNonRegistryRange(x.range);
|
|
1268
|
+
}
|
|
1269
|
+
/** Throws the skip for a request no registry can serve. */
|
|
1270
|
+
function rejectUnfetchable(req) {
|
|
1271
|
+
if (isUnfetchable(req))
|
|
1272
|
+
throw new SkipError(`not a registry version (${clip(req.range, 40)})`);
|
|
1273
|
+
}
|
|
1274
|
+
const NPM_DEP_LINE = /^\s*"([^"]+)"\s*:\s*"([^"]*)"\s*,?\s*$/;
|
|
1275
|
+
/** A patch line opening one of the `NPM_DEP_SECTIONS` objects. */
|
|
1276
|
+
const NPM_DEP_SECTION_HEAD = new RegExp(`"(?:${NPM_DEP_SECTIONS.join("|")})"\\s*:\\s*\\{`);
|
|
1277
|
+
/**
|
|
1278
|
+
* Dependencies a package.json patch adds (or re-ranges). The HEAD file is the
|
|
1279
|
+
* authority on which added lines are dependencies; the patch's own section
|
|
1280
|
+
* tracking is the fallback when the head file cannot be parsed.
|
|
1281
|
+
*/
|
|
1282
|
+
export function npmManifestEdits(patch, headText) {
|
|
1283
|
+
const added = new Map();
|
|
1284
|
+
const addedInDeps = new Set();
|
|
1285
|
+
const removed = new Map();
|
|
1286
|
+
let section = "unknown";
|
|
1287
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
1288
|
+
if (line.startsWith("@@")) {
|
|
1289
|
+
section = "unknown";
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
const sign = line[0];
|
|
1293
|
+
const body = line.slice(1);
|
|
1294
|
+
if (sign === "-") {
|
|
1295
|
+
const m = NPM_DEP_LINE.exec(body);
|
|
1296
|
+
if (m?.[1])
|
|
1297
|
+
removed.set(m[1], m[2] ?? "");
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
if (sign !== "+" && sign !== " ")
|
|
1301
|
+
continue;
|
|
1302
|
+
if (NPM_DEP_SECTION_HEAD.test(body)) {
|
|
1303
|
+
section = "deps";
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
if (/"[^"]*"\s*:\s*\{/.test(body)) {
|
|
1307
|
+
section = "other";
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
if (/^\s*\}/.test(body)) {
|
|
1311
|
+
section = section === "deps" ? "other" : "unknown";
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
if (sign !== "+")
|
|
1315
|
+
continue;
|
|
1316
|
+
const m = NPM_DEP_LINE.exec(body);
|
|
1317
|
+
if (!m?.[1])
|
|
1318
|
+
continue;
|
|
1319
|
+
added.set(m[1], m[2] ?? "");
|
|
1320
|
+
if (section === "deps")
|
|
1321
|
+
addedInDeps.add(m[1]);
|
|
1322
|
+
}
|
|
1323
|
+
const head = headText !== null ? parsePackageJson(headText) : null;
|
|
1324
|
+
const out = [];
|
|
1325
|
+
for (const [name, range] of added) {
|
|
1326
|
+
const isDep = head ? head.deps.has(name) : addedInDeps.has(name);
|
|
1327
|
+
// Same range removed and re-added is a re-flowed line (the previous last
|
|
1328
|
+
// entry gaining a trailing comma when a dependency is appended), not an edit.
|
|
1329
|
+
if (!isDep || removed.get(name) === range)
|
|
1330
|
+
continue;
|
|
1331
|
+
out.push({
|
|
1332
|
+
name,
|
|
1333
|
+
kind: removed.has(name) ? "bumped" : "added",
|
|
1334
|
+
range: head?.deps.get(name) ?? range,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
return out;
|
|
1338
|
+
}
|
|
1339
|
+
export function isRequirementsFile(relPath) {
|
|
1340
|
+
const base = posix.basename(relPath);
|
|
1341
|
+
if (/^requirements.*\.(txt|in)$/i.test(base))
|
|
1342
|
+
return true;
|
|
1343
|
+
return /\.(txt|in)$/i.test(base) && posix.basename(posix.dirname(relPath)) === "requirements";
|
|
1344
|
+
}
|
|
1345
|
+
/** Requirements added (or re-specified) by a requirements*.txt or pyproject.toml patch. */
|
|
1346
|
+
export function pythonManifestEdits(relPath, patch, headText) {
|
|
1347
|
+
const isPyproject = posix.basename(relPath) === "pyproject.toml";
|
|
1348
|
+
const headDeps = isPyproject && headText !== null ? parsePyprojectDeps(headText) : null;
|
|
1349
|
+
const added = new Map();
|
|
1350
|
+
const removed = new Map();
|
|
1351
|
+
const candidates = (body) => {
|
|
1352
|
+
if (!isPyproject) {
|
|
1353
|
+
const line = requirementLine(body);
|
|
1354
|
+
if (!line || line.startsWith("-"))
|
|
1355
|
+
return [];
|
|
1356
|
+
const r = parsePep508(line);
|
|
1357
|
+
return r ? [{ name: r.name, range: r.range, spec: r.range }] : [];
|
|
1358
|
+
}
|
|
1359
|
+
const found = [];
|
|
1360
|
+
for (const s of tomlStrings(body)) {
|
|
1361
|
+
const r = parsePep508(s);
|
|
1362
|
+
if (r)
|
|
1363
|
+
found.push({ name: r.name, range: r.range, spec: r.range });
|
|
1364
|
+
}
|
|
1365
|
+
const kv = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=(.*)$/.exec(body);
|
|
1366
|
+
if (kv?.[1])
|
|
1367
|
+
found.push({
|
|
1368
|
+
name: normPy(kv[1]),
|
|
1369
|
+
range: "*",
|
|
1370
|
+
// A trailing `# comment` is not part of the spec: editing only the
|
|
1371
|
+
// comment is not a bump (a `#` inside a quoted value is kept).
|
|
1372
|
+
spec: (kv[2] ?? "").replace(/\s#[^"']*$/, "").trim(),
|
|
1373
|
+
});
|
|
1374
|
+
return found;
|
|
1375
|
+
};
|
|
1376
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
1377
|
+
if (line.startsWith("@@"))
|
|
1378
|
+
continue;
|
|
1379
|
+
if (line.startsWith("-")) {
|
|
1380
|
+
for (const c of candidates(line.slice(1)))
|
|
1381
|
+
if (!removed.has(c.name))
|
|
1382
|
+
removed.set(c.name, c);
|
|
1383
|
+
}
|
|
1384
|
+
else if (line.startsWith("+")) {
|
|
1385
|
+
for (const c of candidates(line.slice(1)))
|
|
1386
|
+
if (!added.has(c.name))
|
|
1387
|
+
added.set(c.name, c);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
const out = [];
|
|
1391
|
+
for (const [name, { range, spec }] of added) {
|
|
1392
|
+
// Unchanged spec on both sides: the line moved or was re-wrapped.
|
|
1393
|
+
if (name === "python" || removed.get(name)?.spec === spec)
|
|
1394
|
+
continue;
|
|
1395
|
+
// For pyproject the head file decides what is a dependency (a random
|
|
1396
|
+
// `line-length = 100` is a key, not a requirement); without a readable
|
|
1397
|
+
// head, a bare key is never trusted.
|
|
1398
|
+
if (isPyproject && (headDeps ? !headDeps.has(name) : range === "*"))
|
|
1399
|
+
continue;
|
|
1400
|
+
out.push({
|
|
1401
|
+
name,
|
|
1402
|
+
kind: removed.has(name) ? "bumped" : "added",
|
|
1403
|
+
range: headDeps?.get(name)?.range ?? range,
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
return out;
|
|
1407
|
+
}
|
|
1408
|
+
const SEMVER_RE = /^\s*[v=]?\s*(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\s*$/;
|
|
1409
|
+
function parsePre(s) {
|
|
1410
|
+
return s ? s.split(".").map((p) => (/^\d+$/.test(p) ? Number(p) : p)) : [];
|
|
1411
|
+
}
|
|
1412
|
+
export function parseSemver(v) {
|
|
1413
|
+
const m = SEMVER_RE.exec(v);
|
|
1414
|
+
if (!m)
|
|
1415
|
+
return null;
|
|
1416
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: parsePre(m[4]) };
|
|
1417
|
+
}
|
|
1418
|
+
export function compareSemver(a, b) {
|
|
1419
|
+
const d = a.major - b.major || a.minor - b.minor || a.patch - b.patch;
|
|
1420
|
+
if (d)
|
|
1421
|
+
return d;
|
|
1422
|
+
if (!a.pre.length || !b.pre.length)
|
|
1423
|
+
return (a.pre.length ? -1 : 0) + (b.pre.length ? 1 : 0);
|
|
1424
|
+
for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
|
|
1425
|
+
const x = a.pre[i];
|
|
1426
|
+
const y = b.pre[i];
|
|
1427
|
+
if (x === undefined)
|
|
1428
|
+
return -1;
|
|
1429
|
+
if (y === undefined)
|
|
1430
|
+
return 1;
|
|
1431
|
+
if (x === y)
|
|
1432
|
+
continue;
|
|
1433
|
+
if (typeof x === "number" && typeof y === "number")
|
|
1434
|
+
return x - y;
|
|
1435
|
+
if (typeof x === "number")
|
|
1436
|
+
return -1;
|
|
1437
|
+
if (typeof y === "number")
|
|
1438
|
+
return 1;
|
|
1439
|
+
return x < y ? -1 : 1;
|
|
1440
|
+
}
|
|
1441
|
+
return 0;
|
|
1442
|
+
}
|
|
1443
|
+
const PARTIAL_RE = /^[v=]?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
1444
|
+
function parsePartial(s) {
|
|
1445
|
+
if (s === "")
|
|
1446
|
+
return { pre: [] };
|
|
1447
|
+
const r = PARTIAL_RE.exec(s);
|
|
1448
|
+
if (!r)
|
|
1449
|
+
return null;
|
|
1450
|
+
const num = (x) => x === undefined || /^[xX*]$/.test(x) ? undefined : Number(x);
|
|
1451
|
+
const M = num(r[1]);
|
|
1452
|
+
const m = M === undefined ? undefined : num(r[2]);
|
|
1453
|
+
const p = m === undefined ? undefined : num(r[3]);
|
|
1454
|
+
const out = { pre: p === undefined ? [] : parsePre(r[4]) };
|
|
1455
|
+
if (M !== undefined)
|
|
1456
|
+
out.M = M;
|
|
1457
|
+
if (m !== undefined)
|
|
1458
|
+
out.m = m;
|
|
1459
|
+
if (p !== undefined)
|
|
1460
|
+
out.p = p;
|
|
1461
|
+
return out;
|
|
1462
|
+
}
|
|
1463
|
+
function sv(major, minor, patch, pre = []) {
|
|
1464
|
+
return { major, minor, patch, pre };
|
|
1465
|
+
}
|
|
1466
|
+
/** One comparator token → primitive comparators (empty = matches everything). */
|
|
1467
|
+
function desugar(op, P) {
|
|
1468
|
+
const { M, m, p, pre } = P;
|
|
1469
|
+
const NONE = [{ op: "<", v: sv(0, 0, 0) }];
|
|
1470
|
+
const ge = (a, b, c, r = []) => ({
|
|
1471
|
+
op: ">=",
|
|
1472
|
+
v: sv(a, b, c, r),
|
|
1473
|
+
});
|
|
1474
|
+
const lt = (a, b, c) => ({ op: "<", v: sv(a, b, c) });
|
|
1475
|
+
switch (op) {
|
|
1476
|
+
case "":
|
|
1477
|
+
case "=":
|
|
1478
|
+
if (M === undefined)
|
|
1479
|
+
return [];
|
|
1480
|
+
if (m === undefined)
|
|
1481
|
+
return [ge(M, 0, 0), lt(M + 1, 0, 0)];
|
|
1482
|
+
if (p === undefined)
|
|
1483
|
+
return [ge(M, m, 0), lt(M, m + 1, 0)];
|
|
1484
|
+
return [{ op: "=", v: sv(M, m, p, pre) }];
|
|
1485
|
+
case "^":
|
|
1486
|
+
if (M === undefined)
|
|
1487
|
+
return [];
|
|
1488
|
+
if (m === undefined)
|
|
1489
|
+
return [ge(M, 0, 0), lt(M + 1, 0, 0)];
|
|
1490
|
+
if (p === undefined)
|
|
1491
|
+
return M > 0 ? [ge(M, m, 0), lt(M + 1, 0, 0)] : [ge(0, m, 0), lt(0, m + 1, 0)];
|
|
1492
|
+
if (M > 0)
|
|
1493
|
+
return [ge(M, m, p, pre), lt(M + 1, 0, 0)];
|
|
1494
|
+
if (m > 0)
|
|
1495
|
+
return [ge(0, m, p, pre), lt(0, m + 1, 0)];
|
|
1496
|
+
return [ge(0, 0, p, pre), lt(0, 0, p + 1)];
|
|
1497
|
+
case "~":
|
|
1498
|
+
case "~>":
|
|
1499
|
+
if (M === undefined)
|
|
1500
|
+
return [];
|
|
1501
|
+
if (m === undefined)
|
|
1502
|
+
return [ge(M, 0, 0), lt(M + 1, 0, 0)];
|
|
1503
|
+
return [ge(M, m, p ?? 0, pre), lt(M, m + 1, 0)];
|
|
1504
|
+
case ">":
|
|
1505
|
+
if (M === undefined)
|
|
1506
|
+
return NONE;
|
|
1507
|
+
if (m === undefined)
|
|
1508
|
+
return [ge(M + 1, 0, 0)];
|
|
1509
|
+
if (p === undefined)
|
|
1510
|
+
return [ge(M, m + 1, 0)];
|
|
1511
|
+
return [{ op: ">", v: sv(M, m, p, pre) }];
|
|
1512
|
+
case ">=":
|
|
1513
|
+
if (M === undefined)
|
|
1514
|
+
return [];
|
|
1515
|
+
return [ge(M, m ?? 0, p ?? 0, pre)];
|
|
1516
|
+
case "<":
|
|
1517
|
+
if (M === undefined)
|
|
1518
|
+
return NONE;
|
|
1519
|
+
return [{ op: "<", v: sv(M, m ?? 0, p ?? 0, pre) }];
|
|
1520
|
+
case "<=":
|
|
1521
|
+
if (M === undefined)
|
|
1522
|
+
return [];
|
|
1523
|
+
if (m === undefined)
|
|
1524
|
+
return [lt(M + 1, 0, 0)];
|
|
1525
|
+
if (p === undefined)
|
|
1526
|
+
return [lt(M, m + 1, 0)];
|
|
1527
|
+
return [{ op: "<=", v: sv(M, m, p, pre) }];
|
|
1528
|
+
default:
|
|
1529
|
+
return NONE;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
/** An npm range → alternatives of comparator sets, or null when unparseable. */
|
|
1533
|
+
function parseRange(range) {
|
|
1534
|
+
const out = [];
|
|
1535
|
+
for (const alt of range.split("||")) {
|
|
1536
|
+
const s = alt.trim();
|
|
1537
|
+
const hy = /^(\S+)\s+-\s+(\S+)$/.exec(s);
|
|
1538
|
+
if (hy) {
|
|
1539
|
+
const a = parsePartial(hy[1] ?? "");
|
|
1540
|
+
const b = parsePartial(hy[2] ?? "");
|
|
1541
|
+
if (!a || !b)
|
|
1542
|
+
return null;
|
|
1543
|
+
out.push([...desugar(">=", a), ...desugar("<=", b)]);
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
const set = [];
|
|
1547
|
+
for (const tok of s
|
|
1548
|
+
.replace(/(<=|>=|~>|[<>=~^])\s+/g, "$1")
|
|
1549
|
+
.split(/\s+/)
|
|
1550
|
+
.filter(Boolean)) {
|
|
1551
|
+
const t = /^(<=|>=|~>|[<>=~^])?(.*)$/.exec(tok);
|
|
1552
|
+
const P = parsePartial(t?.[2] ?? "");
|
|
1553
|
+
if (!P)
|
|
1554
|
+
return null;
|
|
1555
|
+
set.push(...desugar(t?.[1] ?? "", P));
|
|
1556
|
+
}
|
|
1557
|
+
out.push(set);
|
|
1558
|
+
}
|
|
1559
|
+
return out;
|
|
1560
|
+
}
|
|
1561
|
+
function testSet(v, set) {
|
|
1562
|
+
for (const c of set) {
|
|
1563
|
+
const d = compareSemver(v, c.v);
|
|
1564
|
+
const ok = c.op === "<"
|
|
1565
|
+
? d < 0
|
|
1566
|
+
: c.op === "<="
|
|
1567
|
+
? d <= 0
|
|
1568
|
+
: c.op === ">"
|
|
1569
|
+
? d > 0
|
|
1570
|
+
: c.op === ">="
|
|
1571
|
+
? d >= 0
|
|
1572
|
+
: d === 0;
|
|
1573
|
+
if (!ok)
|
|
1574
|
+
return false;
|
|
1575
|
+
}
|
|
1576
|
+
// node-semver's rule: a prerelease only satisfies a range that itself
|
|
1577
|
+
// names a prerelease of the same major.minor.patch.
|
|
1578
|
+
if (v.pre.length) {
|
|
1579
|
+
return set.some((c) => c.v.pre.length > 0 &&
|
|
1580
|
+
c.v.major === v.major &&
|
|
1581
|
+
c.v.minor === v.minor &&
|
|
1582
|
+
c.v.patch === v.patch);
|
|
1583
|
+
}
|
|
1584
|
+
return true;
|
|
1585
|
+
}
|
|
1586
|
+
export function satisfiesRange(version, range) {
|
|
1587
|
+
const v = parseSemver(version);
|
|
1588
|
+
const r = parseRange(range);
|
|
1589
|
+
return !!v && !!r && r.some((set) => testSet(v, set));
|
|
1590
|
+
}
|
|
1591
|
+
export function maxSatisfying(versions, range) {
|
|
1592
|
+
const r = parseRange(range);
|
|
1593
|
+
if (!r)
|
|
1594
|
+
return null;
|
|
1595
|
+
let best = null;
|
|
1596
|
+
for (const raw of versions) {
|
|
1597
|
+
const v = parseSemver(raw);
|
|
1598
|
+
if (!v || !r.some((set) => testSet(v, set)))
|
|
1599
|
+
continue;
|
|
1600
|
+
if (!best || compareSemver(v, best.v) > 0)
|
|
1601
|
+
best = { raw, v };
|
|
1602
|
+
}
|
|
1603
|
+
return best?.raw ?? null;
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* npm's own choice for a range: a dist-tag named outright (`latest`, `next`),
|
|
1607
|
+
* else `latest` when it satisfies, else the highest satisfying version.
|
|
1608
|
+
*/
|
|
1609
|
+
export function pickNpmVersion(versions, range, distTags = {}) {
|
|
1610
|
+
const r = range.trim() || "*";
|
|
1611
|
+
const tagged = str(distTags[r]);
|
|
1612
|
+
if (tagged && versions.includes(tagged))
|
|
1613
|
+
return tagged;
|
|
1614
|
+
const latest = str(distTags.latest);
|
|
1615
|
+
if (latest && versions.includes(latest) && satisfiesRange(latest, r))
|
|
1616
|
+
return latest;
|
|
1617
|
+
return maxSatisfying(versions, r);
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* The locked version a file resolves to, and whether the copy it resolves to
|
|
1621
|
+
* — in the same nearest-workspace pool the version came from — is foreign.
|
|
1622
|
+
*/
|
|
1623
|
+
function pickLocked(entries, range, relInLockDir) {
|
|
1624
|
+
const pool = lockPool(entries, relInLockDir);
|
|
1625
|
+
// A range a lock entry names outright selects THAT entry, so its own mark
|
|
1626
|
+
// decides; a version reached any other way may be any copy at it.
|
|
1627
|
+
const exact = range ? pool.find((e) => e.specs?.includes(range)) : undefined;
|
|
1628
|
+
if (exact)
|
|
1629
|
+
return { version: exact.version, foreign: !!exact.foreign };
|
|
1630
|
+
const version = pickFromPool(pool, range);
|
|
1631
|
+
if (!version)
|
|
1632
|
+
return null;
|
|
1633
|
+
return { version, foreign: pool.some((e) => e.version === version && e.foreign) };
|
|
1634
|
+
}
|
|
1635
|
+
function lockPool(entries, relInLockDir) {
|
|
1636
|
+
const inScope = entries.filter((e) => e.scope && (relInLockDir === e.scope || relInLockDir.startsWith(`${e.scope}/`)));
|
|
1637
|
+
// Node resolves from the NEAREST node_modules: only the deepest enclosing
|
|
1638
|
+
// workspace's copies count, never a max across its ancestors.
|
|
1639
|
+
const depth = Math.max(0, ...inScope.map((e) => e.scope?.length ?? 0));
|
|
1640
|
+
const scoped = inScope.filter((e) => (e.scope?.length ?? 0) === depth);
|
|
1641
|
+
// Outside every workspace that holds a copy and with none hoisted, the file
|
|
1642
|
+
// resolves to nothing: a sibling workspace's copy is not its dependency.
|
|
1643
|
+
return scoped.length ? scoped : entries.filter((e) => !e.scope);
|
|
1644
|
+
}
|
|
1645
|
+
function pickFromPool(pool, range) {
|
|
1646
|
+
if (pool.length === 1)
|
|
1647
|
+
return pool[0]?.version ?? null;
|
|
1648
|
+
if (range) {
|
|
1649
|
+
const m = maxSatisfying(pool.map((e) => e.version), range);
|
|
1650
|
+
if (m)
|
|
1651
|
+
return m;
|
|
1652
|
+
}
|
|
1653
|
+
return (maxSatisfying(pool.map((e) => e.version), "*") ??
|
|
1654
|
+
pool[0]?.version ??
|
|
1655
|
+
null);
|
|
1656
|
+
}
|
|
1657
|
+
const PY_VERSION_RE = /^\s*v?(?:(\d+)!)?(\d+(?:\.\d+)*)((?:[-_.]?(?:a|b|c|rc|alpha|beta|pre|preview)(?:[-_.]?\d+)?)?)((?:-\d+|[-_.]?(?:post|rev|r)(?:[-_.]?\d+)?)?)((?:[-_.]?dev(?:[-_.]?\d+)?)?)(?:\+[a-z0-9]+(?:[-_.][a-z0-9]+)*)?\s*$/i;
|
|
1658
|
+
function parsePyVersion(v) {
|
|
1659
|
+
const m = PY_VERSION_RE.exec(v);
|
|
1660
|
+
if (!m)
|
|
1661
|
+
return null;
|
|
1662
|
+
return {
|
|
1663
|
+
epoch: Number(m[1] ?? 0),
|
|
1664
|
+
release: (m[2] ?? "0").split(".").map(Number),
|
|
1665
|
+
final: !m[3] && !m[4] && !m[5],
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
function cmpRelease(a, b) {
|
|
1669
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
1670
|
+
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
1671
|
+
if (d)
|
|
1672
|
+
return d;
|
|
1673
|
+
}
|
|
1674
|
+
return 0;
|
|
1675
|
+
}
|
|
1676
|
+
function pyClause(version, clause) {
|
|
1677
|
+
const m = /^(===|==|!=|~=|>=|<=|>|<|\^|~|=)?\s*(.*)$/.exec(clause.trim());
|
|
1678
|
+
const op = m?.[1] ?? "";
|
|
1679
|
+
const target = (m?.[2] ?? "").trim();
|
|
1680
|
+
if (target === "*" || target === "")
|
|
1681
|
+
return op === "" || op === "==" || op === "=";
|
|
1682
|
+
const wildcard = target.endsWith(".*");
|
|
1683
|
+
const tv = parsePyVersion(wildcard ? target.slice(0, -2) : target);
|
|
1684
|
+
if (!tv)
|
|
1685
|
+
return null;
|
|
1686
|
+
const t = tv.release;
|
|
1687
|
+
const v = version.release;
|
|
1688
|
+
const epoch = version.epoch - tv.epoch;
|
|
1689
|
+
const prefix = () => epoch === 0 && t.every((n, i) => (v[i] ?? 0) === n);
|
|
1690
|
+
const c = epoch || cmpRelease(v, t);
|
|
1691
|
+
switch (op) {
|
|
1692
|
+
case "":
|
|
1693
|
+
case "=":
|
|
1694
|
+
case "==":
|
|
1695
|
+
case "===":
|
|
1696
|
+
return wildcard ? prefix() : c === 0;
|
|
1697
|
+
case "!=":
|
|
1698
|
+
return wildcard ? !prefix() : c !== 0;
|
|
1699
|
+
case ">=":
|
|
1700
|
+
return c >= 0;
|
|
1701
|
+
case "<=":
|
|
1702
|
+
return c <= 0;
|
|
1703
|
+
case ">":
|
|
1704
|
+
return c > 0;
|
|
1705
|
+
case "<":
|
|
1706
|
+
return c < 0;
|
|
1707
|
+
case "~=": {
|
|
1708
|
+
if (t.length < 2)
|
|
1709
|
+
return null;
|
|
1710
|
+
const upper = t.slice(0, -1);
|
|
1711
|
+
upper[upper.length - 1] = (upper[upper.length - 1] ?? 0) + 1;
|
|
1712
|
+
return epoch === 0 && c >= 0 && cmpRelease(v, upper) < 0;
|
|
1713
|
+
}
|
|
1714
|
+
case "^": {
|
|
1715
|
+
// Poetry caret: bump the first non-zero component.
|
|
1716
|
+
let i = t.findIndex((n) => n !== 0);
|
|
1717
|
+
if (i < 0)
|
|
1718
|
+
i = t.length - 1;
|
|
1719
|
+
const upper = t.slice(0, i + 1);
|
|
1720
|
+
upper[i] = (upper[i] ?? 0) + 1;
|
|
1721
|
+
return epoch === 0 && c >= 0 && cmpRelease(v, upper) < 0;
|
|
1722
|
+
}
|
|
1723
|
+
case "~": {
|
|
1724
|
+
const upper = t.length >= 2 ? [t[0] ?? 0, (t[1] ?? 0) + 1] : [(t[0] ?? 0) + 1];
|
|
1725
|
+
return epoch === 0 && c >= 0 && cmpRelease(v, upper) < 0;
|
|
1726
|
+
}
|
|
1727
|
+
default:
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Does a PyPI version satisfy a PEP 440 / Poetry specifier? Pre-, post- and
|
|
1733
|
+
* dev-releases never match a range — only an exact pin fetches one, and that
|
|
1734
|
+
* path never asks this function.
|
|
1735
|
+
*/
|
|
1736
|
+
export function pyVersionMatches(version, spec) {
|
|
1737
|
+
const v = parsePyVersion(version);
|
|
1738
|
+
if (!v || !v.final)
|
|
1739
|
+
return false;
|
|
1740
|
+
return spec.split("||").some((alt) => alt.split(",").every((cl) => pyClause(v, cl) === true));
|
|
1741
|
+
}
|
|
1742
|
+
function maxPyVersion(versions, spec) {
|
|
1743
|
+
let best = null;
|
|
1744
|
+
for (const raw of versions) {
|
|
1745
|
+
if (!pyVersionMatches(raw, spec))
|
|
1746
|
+
continue;
|
|
1747
|
+
const parsed = parsePyVersion(raw);
|
|
1748
|
+
if (!best ||
|
|
1749
|
+
(parsed.epoch - best.parsed.epoch || cmpRelease(parsed.release, best.parsed.release)) > 0)
|
|
1750
|
+
best = { raw, parsed };
|
|
1751
|
+
}
|
|
1752
|
+
return best?.raw ?? null;
|
|
1753
|
+
}
|
|
1754
|
+
function treeIo(root, readFile) {
|
|
1755
|
+
const abs = (rel) => join(root, rel);
|
|
1756
|
+
let realRoot = null;
|
|
1757
|
+
// The worktree is the PR author's content: a symlinked `package.json`
|
|
1758
|
+
// pointing at a host file must not be read (names from it would reach the
|
|
1759
|
+
// prompt), so the default reader refuses anything resolving outside it.
|
|
1760
|
+
const inside = (p) => {
|
|
1761
|
+
try {
|
|
1762
|
+
realRoot ??= realpathSync(root);
|
|
1763
|
+
const real = realpathSync(p);
|
|
1764
|
+
return real === realRoot || real.startsWith(realRoot + sep);
|
|
1765
|
+
}
|
|
1766
|
+
catch {
|
|
1767
|
+
return false;
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
const injected = (rel) => {
|
|
1771
|
+
try {
|
|
1772
|
+
return readFile ? readFile(abs(rel)) : null;
|
|
1773
|
+
}
|
|
1774
|
+
catch {
|
|
1775
|
+
return null;
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
return {
|
|
1779
|
+
read(rel, maxBytes) {
|
|
1780
|
+
if (readFile) {
|
|
1781
|
+
const t = injected(rel);
|
|
1782
|
+
return t !== null && t.length <= maxBytes ? t : null;
|
|
1783
|
+
}
|
|
1784
|
+
try {
|
|
1785
|
+
const p = abs(rel);
|
|
1786
|
+
const st = lstatSync(p);
|
|
1787
|
+
if (!st.isFile() || st.size > maxBytes || !inside(p))
|
|
1788
|
+
return null;
|
|
1789
|
+
return readFileSync(p, "utf8");
|
|
1790
|
+
}
|
|
1791
|
+
catch {
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
},
|
|
1795
|
+
exists(rel) {
|
|
1796
|
+
try {
|
|
1797
|
+
statSync(abs(rel));
|
|
1798
|
+
return true;
|
|
1799
|
+
}
|
|
1800
|
+
catch {
|
|
1801
|
+
return injected(rel) !== null;
|
|
1802
|
+
}
|
|
1803
|
+
},
|
|
1804
|
+
isDir(rel) {
|
|
1805
|
+
try {
|
|
1806
|
+
return statSync(abs(rel)).isDirectory();
|
|
1807
|
+
}
|
|
1808
|
+
catch {
|
|
1809
|
+
return false;
|
|
1810
|
+
}
|
|
1811
|
+
},
|
|
1812
|
+
list(rel) {
|
|
1813
|
+
try {
|
|
1814
|
+
return readdirSync(abs(rel));
|
|
1815
|
+
}
|
|
1816
|
+
catch {
|
|
1817
|
+
return [];
|
|
1818
|
+
}
|
|
1819
|
+
},
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
/** Project defaults identify an otherwise implicit lock source. Never substitute
|
|
1823
|
+
* a public archive for a release whose configured index is private. */
|
|
1824
|
+
function tomlObject(text) {
|
|
1825
|
+
try {
|
|
1826
|
+
const doc = text === null ? {} : parseToml(text);
|
|
1827
|
+
return isObj(doc) ? doc : {};
|
|
1828
|
+
}
|
|
1829
|
+
catch {
|
|
1830
|
+
return {};
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
function childObject(obj, key) {
|
|
1834
|
+
const value = obj[key];
|
|
1835
|
+
return isObj(value) ? value : {};
|
|
1836
|
+
}
|
|
1837
|
+
function pythonDefaults(pyproject, uvConfig) {
|
|
1838
|
+
const doc = tomlObject(pyproject);
|
|
1839
|
+
const tool = childObject(doc, "tool");
|
|
1840
|
+
const poetry = childObject(tool, "poetry");
|
|
1841
|
+
const sources = Array.isArray(poetry.source) ? poetry.source : [];
|
|
1842
|
+
const privatePrimary = sources.some((source) => isObj(source) &&
|
|
1843
|
+
(source.priority === "primary" ||
|
|
1844
|
+
source.default === true ||
|
|
1845
|
+
(source.priority === undefined && source.secondary !== true && source.default !== false)) &&
|
|
1846
|
+
!(typeof source.url === "string"
|
|
1847
|
+
? isPublicPypiIndex(source.url)
|
|
1848
|
+
: typeof source.name === "string" && source.name.toLowerCase() === "pypi"));
|
|
1849
|
+
// An explicit PyPI constraint remains public even when an implicit primary
|
|
1850
|
+
// source is private. Other explicit source constraints are already parsed.
|
|
1851
|
+
const publicPoetry = new Set();
|
|
1852
|
+
if (pyproject !== null)
|
|
1853
|
+
parsePyprojectDeps(pyproject, publicPoetry);
|
|
1854
|
+
const uv = uvConfig === null ? childObject(tool, "uv") : tomlObject(uvConfig);
|
|
1855
|
+
const index = uv["default-index"] ?? uv["index-url"];
|
|
1856
|
+
const defaults = Array.isArray(uv.index)
|
|
1857
|
+
? uv.index.filter((x) => isObj(x) && x.default === true)
|
|
1858
|
+
: [];
|
|
1859
|
+
return {
|
|
1860
|
+
poetry: privatePrimary,
|
|
1861
|
+
publicPoetry,
|
|
1862
|
+
uv: defaults.length > 0
|
|
1863
|
+
? defaults.some((x) => !isObj(x) || typeof x.url !== "string" || !isPublicPypiIndex(x.url))
|
|
1864
|
+
: typeof index === "string" && !isPublicPypiIndex(index),
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
class Resolver {
|
|
1868
|
+
io;
|
|
1869
|
+
dirs = new Map();
|
|
1870
|
+
constructor(io) {
|
|
1871
|
+
this.io = io;
|
|
1872
|
+
}
|
|
1873
|
+
dir(rel) {
|
|
1874
|
+
const hit = this.dirs.get(rel);
|
|
1875
|
+
if (hit)
|
|
1876
|
+
return hit;
|
|
1877
|
+
const read = (name) => this.io.read(posix.join(rel, name), MAX_MANIFEST_BYTES);
|
|
1878
|
+
const npmrc = read(".npmrc");
|
|
1879
|
+
const registry = npmrc
|
|
1880
|
+
?.split(/\r?\n/)
|
|
1881
|
+
.map((line) => /^\s*registry\s*=\s*(.*?)\s*$/.exec(line))
|
|
1882
|
+
.filter((match) => match !== null)
|
|
1883
|
+
.at(-1)?.[1];
|
|
1884
|
+
const parent = rel ? this.dir(relDirOf(rel)) : null;
|
|
1885
|
+
const npmDefaultForeign = registry === undefined ? (parent?.npmDefaultForeign ?? false) : isForeignResolved(registry);
|
|
1886
|
+
const npmScopeForeign = new Map(parent?.npmScopeForeign);
|
|
1887
|
+
for (const line of npmrc?.split(/\r?\n/) ?? []) {
|
|
1888
|
+
const match = /^\s*(@[^:\s]+):registry\s*=\s*(.*?)\s*$/.exec(line);
|
|
1889
|
+
if (match)
|
|
1890
|
+
npmScopeForeign.set(match[1], isForeignResolved(match[2]));
|
|
1891
|
+
}
|
|
1892
|
+
const registryForeign = (name) => npmScopeForeign.get(name.split("/")[0]) ?? npmDefaultForeign;
|
|
1893
|
+
const pyproject = read("pyproject.toml");
|
|
1894
|
+
const defaults = pythonDefaults(pyproject, read("uv.toml"));
|
|
1895
|
+
const npmLocks = [];
|
|
1896
|
+
for (const f of ["npm-shrinkwrap.json", "package-lock.json"]) {
|
|
1897
|
+
const t = read(f);
|
|
1898
|
+
const l = t !== null ? parsePackageLock(t, registryForeign) : null;
|
|
1899
|
+
if (l)
|
|
1900
|
+
npmLocks.push(l);
|
|
1901
|
+
}
|
|
1902
|
+
const yarn = read("yarn.lock");
|
|
1903
|
+
if (yarn !== null)
|
|
1904
|
+
npmLocks.push(parseYarnLock(yarn));
|
|
1905
|
+
const pnpm = read("pnpm-lock.yaml");
|
|
1906
|
+
if (pnpm !== null)
|
|
1907
|
+
npmLocks.push(parsePnpmLock(pnpm, registryForeign));
|
|
1908
|
+
const pkgText = read("package.json");
|
|
1909
|
+
const pkg = pkgText !== null ? parsePackageJson(pkgText) : null;
|
|
1910
|
+
const pyPins = new Map();
|
|
1911
|
+
const pyLocal = new Set();
|
|
1912
|
+
const pyDecl = new Map();
|
|
1913
|
+
const pins = (m) => {
|
|
1914
|
+
for (const [k, v] of m)
|
|
1915
|
+
if (!pyPins.has(k))
|
|
1916
|
+
pyPins.set(k, v);
|
|
1917
|
+
};
|
|
1918
|
+
for (const f of ["uv.lock", "poetry.lock"]) {
|
|
1919
|
+
const t = read(f);
|
|
1920
|
+
if (t !== null)
|
|
1921
|
+
pins(parsePythonLock(t, pyLocal, f === "uv.lock" ? defaults.uv : defaults.poetry, f === "poetry.lock" ? defaults.publicPoetry : undefined));
|
|
1922
|
+
}
|
|
1923
|
+
const pipfile = read("Pipfile.lock");
|
|
1924
|
+
if (pipfile !== null)
|
|
1925
|
+
pins(parsePipfileLock(pipfile));
|
|
1926
|
+
const reqFiles = new Set(["requirements.txt"]);
|
|
1927
|
+
for (const f of this.io.list(rel))
|
|
1928
|
+
if (isRequirementsFile(f))
|
|
1929
|
+
reqFiles.add(f);
|
|
1930
|
+
const reqDir = posix.join(rel, "requirements");
|
|
1931
|
+
if (this.io.isDir(reqDir))
|
|
1932
|
+
for (const f of this.io.list(reqDir))
|
|
1933
|
+
if (isRequirementsFile(`requirements/${f}`))
|
|
1934
|
+
reqFiles.add(`requirements/${f}`);
|
|
1935
|
+
for (const f of [...reqFiles].sort()) {
|
|
1936
|
+
const t = read(f);
|
|
1937
|
+
if (t === null)
|
|
1938
|
+
continue;
|
|
1939
|
+
for (const [k, r] of parseRequirements(t)) {
|
|
1940
|
+
if (r.pin && !pyPins.has(k))
|
|
1941
|
+
pyPins.set(k, r.pin);
|
|
1942
|
+
if (!pyDecl.has(k))
|
|
1943
|
+
pyDecl.set(k, r);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
if (pyproject !== null)
|
|
1947
|
+
for (const [k, r] of parsePyprojectDeps(pyproject))
|
|
1948
|
+
if (!pyDecl.has(k))
|
|
1949
|
+
pyDecl.set(k, r);
|
|
1950
|
+
const uv = childObject(childObject(tomlObject(pyproject), "tool"), "uv");
|
|
1951
|
+
for (const [name, source] of Object.entries(childObject(uv, "sources"))) {
|
|
1952
|
+
const key = normPy(name);
|
|
1953
|
+
if (!pyDecl.has(key) || pyPins.has(key))
|
|
1954
|
+
continue;
|
|
1955
|
+
const entries = Array.isArray(source) ? source : [source];
|
|
1956
|
+
if (entries.some((entry) => isObj(entry) && (entry.workspace === true || typeof entry.path === "string"))) {
|
|
1957
|
+
pyDecl.set(key, { range: "file:uv-source", pin: null });
|
|
1958
|
+
}
|
|
1959
|
+
else if (entries.some((entry) => isObj(entry) &&
|
|
1960
|
+
["git", "url", "index"].some((field) => typeof entry[field] === "string"))) {
|
|
1961
|
+
pyDecl.set(key, { range: "@ uv source", pin: null });
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
for (const [name, req] of pyDecl)
|
|
1965
|
+
if (!pyPins.has(name) &&
|
|
1966
|
+
!isLocalRange(req.range) &&
|
|
1967
|
+
(defaults.uv || (defaults.poetry && !defaults.publicPoetry.has(name))))
|
|
1968
|
+
pyDecl.set(name, { range: `${PY_FOREIGN_PIN_PREFIX}index source`, pin: null });
|
|
1969
|
+
const info = {
|
|
1970
|
+
npmLocks,
|
|
1971
|
+
npmDefaultForeign,
|
|
1972
|
+
npmScopeForeign,
|
|
1973
|
+
pkg,
|
|
1974
|
+
pyPins,
|
|
1975
|
+
pyLocal,
|
|
1976
|
+
pyDecl,
|
|
1977
|
+
};
|
|
1978
|
+
this.dirs.set(rel, info);
|
|
1979
|
+
return info;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* An npm package as seen from one file: the nearest lockfile that pins it
|
|
1983
|
+
* and the nearest package.json that declares it (monorepos: walk up).
|
|
1984
|
+
* "first-party" for workspace / link / the package itself; null for
|
|
1985
|
+
* UNDECLARED imports — those are
|
|
1986
|
+
* path aliases (`components/x`, `@app/x`) or a missing dependency, and
|
|
1987
|
+
* fetching the registry package that happens to share the name would hand
|
|
1988
|
+
* the reviewer an unrelated library.
|
|
1989
|
+
*/
|
|
1990
|
+
npm(name, relFile) {
|
|
1991
|
+
let range;
|
|
1992
|
+
let defaultForeign = false;
|
|
1993
|
+
let locked;
|
|
1994
|
+
let anyManifest = false;
|
|
1995
|
+
for (const d of ancestors(relDirOf(relFile))) {
|
|
1996
|
+
const info = this.dir(d);
|
|
1997
|
+
if (info.pkg) {
|
|
1998
|
+
anyManifest = true;
|
|
1999
|
+
if (info.pkg.name === name)
|
|
2000
|
+
return "first-party";
|
|
2001
|
+
if (range === undefined && info.pkg.deps.has(name)) {
|
|
2002
|
+
range = info.pkg.deps.get(name);
|
|
2003
|
+
defaultForeign = info.npmScopeForeign.get(name.split("/")[0]) ?? info.npmDefaultForeign;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
for (const l of info.npmLocks) {
|
|
2007
|
+
anyManifest = true;
|
|
2008
|
+
if (!locked && isLinked(l, name, d ? posix.relative(d, relFile) : relFile))
|
|
2009
|
+
return "first-party";
|
|
2010
|
+
const e = l.entries.get(name);
|
|
2011
|
+
if (!locked && e?.length)
|
|
2012
|
+
locked = { entries: e, dir: d };
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
if (range !== undefined && isLocalRange(range))
|
|
2016
|
+
return "first-party";
|
|
2017
|
+
if (range !== undefined && isNonRegistryRange(range))
|
|
2018
|
+
return { name, version: null, range };
|
|
2019
|
+
if (locked) {
|
|
2020
|
+
const rel = locked.dir ? posix.relative(locked.dir, relFile) : relFile;
|
|
2021
|
+
const picked = pickLocked(locked.entries, range, rel);
|
|
2022
|
+
// Locked to a private registry / git / URL: skip rather than hand the
|
|
2023
|
+
// reviewer npmjs's same-named release as the PR's locked source.
|
|
2024
|
+
if (picked?.foreign)
|
|
2025
|
+
return { name, version: null, range: NPM_FOREIGN_PIN };
|
|
2026
|
+
const version = picked?.version;
|
|
2027
|
+
if (version)
|
|
2028
|
+
return range !== undefined ? { name, version, range } : { name, version };
|
|
2029
|
+
}
|
|
2030
|
+
if (range !== undefined)
|
|
2031
|
+
return { name, version: null, range: defaultForeign ? NPM_FOREIGN_PIN : range };
|
|
2032
|
+
// No npm manifest anywhere on the path: nothing could alias the name, so
|
|
2033
|
+
// the bare import can only mean the registry package.
|
|
2034
|
+
return anyManifest ? null : { name, version: null, range: "*" };
|
|
2035
|
+
}
|
|
2036
|
+
/** A Python distribution (normalised name) as seen from one file. */
|
|
2037
|
+
pyDist(dist, relFile) {
|
|
2038
|
+
let pin;
|
|
2039
|
+
let pinDepth = -1;
|
|
2040
|
+
let local = false;
|
|
2041
|
+
let decl;
|
|
2042
|
+
let declDepth = -1;
|
|
2043
|
+
ancestors(relDirOf(relFile)).forEach((d, depth) => {
|
|
2044
|
+
const info = this.dir(d);
|
|
2045
|
+
// The nearest lockfile that knows the name decides: a pin, or local.
|
|
2046
|
+
if (pin === undefined && !local) {
|
|
2047
|
+
pin = info.pyPins.get(dist);
|
|
2048
|
+
if (pin !== undefined)
|
|
2049
|
+
pinDepth = depth;
|
|
2050
|
+
else
|
|
2051
|
+
local = info.pyLocal.has(dist);
|
|
2052
|
+
}
|
|
2053
|
+
if (decl === undefined) {
|
|
2054
|
+
decl = info.pyDecl.get(dist);
|
|
2055
|
+
if (decl !== undefined)
|
|
2056
|
+
declDepth = depth;
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
2059
|
+
if (local || (decl && isLocalRange(decl.range)))
|
|
2060
|
+
return "first-party";
|
|
2061
|
+
// Locked to git / a URL / a private index: a non-registry range, so
|
|
2062
|
+
// preparation skips it rather than substituting PyPI's release.
|
|
2063
|
+
if (pin?.startsWith(PY_FOREIGN_PIN_PREFIX))
|
|
2064
|
+
return { name: dist, version: null, range: pin };
|
|
2065
|
+
// A nearer declaration naming a non-registry source (a direct reference,
|
|
2066
|
+
// a private index) is not what an ancestor's public pin installs here.
|
|
2067
|
+
if (decl && isNonRegistryRange(decl.range) && (pin === undefined || pinDepth > declDepth))
|
|
2068
|
+
return { name: dist, version: null, range: decl.range };
|
|
2069
|
+
if (pin === undefined && !decl)
|
|
2070
|
+
return null;
|
|
2071
|
+
const version = pin ?? decl?.pin ?? null;
|
|
2072
|
+
return decl ? { name: dist, version, range: decl.range } : { name: dist, version };
|
|
2073
|
+
}
|
|
2074
|
+
/** Is `top` a module of the repo itself, as seen from this file? */
|
|
2075
|
+
pyFirstParty(top, relFile) {
|
|
2076
|
+
const dirs = new Set(ancestors(relDirOf(relFile)).flatMap((dir) => [dir, posix.join(dir, "src")]));
|
|
2077
|
+
for (const d of dirs) {
|
|
2078
|
+
const base = posix.join(d, top);
|
|
2079
|
+
if (this.io.exists(`${base}.py`) || this.io.exists(`${base}/__init__.py`))
|
|
2080
|
+
return true;
|
|
2081
|
+
// A namespace package (no __init__.py) still counts when it holds Python.
|
|
2082
|
+
if (this.io.isDir(base) && this.io.list(base).some((f) => f.endsWith(".py")))
|
|
2083
|
+
return true;
|
|
2084
|
+
}
|
|
2085
|
+
return false;
|
|
2086
|
+
}
|
|
2087
|
+
/** The distributions one Python import statement pulls in (usually 0 or 1). */
|
|
2088
|
+
py(imp, relFile) {
|
|
2089
|
+
const parts = imp.module.split(".");
|
|
2090
|
+
const top = parts[0] ?? "";
|
|
2091
|
+
if (!top || PY_STDLIB.has(top) || this.pyFirstParty(top, relFile))
|
|
2092
|
+
return [];
|
|
2093
|
+
const lookup = (candidate) => {
|
|
2094
|
+
const alias = Object.hasOwn(PY_ALIASES, candidate) ? PY_ALIASES[candidate] : undefined;
|
|
2095
|
+
const r = this.pyDist(normPy(alias ?? candidate), relFile);
|
|
2096
|
+
if (r === null && alias)
|
|
2097
|
+
return { name: normPy(alias), version: null, range: "*" };
|
|
2098
|
+
return r;
|
|
2099
|
+
};
|
|
2100
|
+
const out = [];
|
|
2101
|
+
// `from google.cloud import storage, bigquery` names two distributions;
|
|
2102
|
+
// only lowercase names can be submodules, so classes are not tried.
|
|
2103
|
+
for (const n of imp.names) {
|
|
2104
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(n))
|
|
2105
|
+
continue;
|
|
2106
|
+
const r = lookup(`${imp.module}.${n}`);
|
|
2107
|
+
if (r && r !== "first-party")
|
|
2108
|
+
out.push(r);
|
|
2109
|
+
}
|
|
2110
|
+
if (out.length)
|
|
2111
|
+
return out;
|
|
2112
|
+
for (let i = parts.length; i >= 1; i--) {
|
|
2113
|
+
const r = lookup(parts.slice(0, i).join("."));
|
|
2114
|
+
if (r === "first-party")
|
|
2115
|
+
return [];
|
|
2116
|
+
if (r)
|
|
2117
|
+
return [r];
|
|
2118
|
+
}
|
|
2119
|
+
return [];
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
/** Why a manifest edit puts a package on the list. */
|
|
2123
|
+
function editWhy(e, rel) {
|
|
2124
|
+
return `${e.kind === "bumped" ? "bumped in" : "added to"} ${rel}`;
|
|
2125
|
+
}
|
|
2126
|
+
function renderWhy(a) {
|
|
2127
|
+
const parts = a.manifestWhys.slice(0, 3);
|
|
2128
|
+
if (a.importers.length) {
|
|
2129
|
+
const more = a.importers.length - 3;
|
|
2130
|
+
parts.push(`imported by ${a.importers.slice(0, 3).join(", ")}${more > 0 ? ` +${more} more` : ""}`);
|
|
2131
|
+
}
|
|
2132
|
+
return parts.join("; ");
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Which third-party packages matter for THIS PR, most important first:
|
|
2136
|
+
* dependencies the diff adds to a manifest, then packages imported by the
|
|
2137
|
+
* changed files (by how many import them). Pure over the tree; `readFile`
|
|
2138
|
+
* (absolute path → text or null) replaces file READS for tests — directory
|
|
2139
|
+
* listings (a `requirements/` dir) still come from the filesystem. Not
|
|
2140
|
+
* truncated — the per-PR cap is applied at prepare time so the overflow is
|
|
2141
|
+
* visible as `skipped`.
|
|
2142
|
+
*/
|
|
2143
|
+
export function planDependencyRequests(tree, changedFiles, readFile) {
|
|
2144
|
+
const io = treeIo(resolve(tree), readFile);
|
|
2145
|
+
const resolver = new Resolver(io);
|
|
2146
|
+
// Keyed by package, one Acc per distinct resolved VERSION: two workspaces can
|
|
2147
|
+
// lock one package at different versions, and each importer must be shown the
|
|
2148
|
+
// sources it actually runs against. An unresolved (null) sighting joins the
|
|
2149
|
+
// package's first registry entry it is compatible with — another unresolved
|
|
2150
|
+
// entry whose range it shares (`*` or no range shares any; an unresolved `*`
|
|
2151
|
+
// entry a specific range joins narrows to that range, whichever was seen
|
|
2152
|
+
// first), or a resolved entry of the same range or whose version its range
|
|
2153
|
+
// admits — and a later resolution fills in a null entry on the same terms,
|
|
2154
|
+
// narrowing a `*` or range-less one to its own range. So an importer is never
|
|
2155
|
+
// credited with a version its own declared range was not resolved against.
|
|
2156
|
+
// A sighting preparation SKIPS (a git / URL / private-registry source)
|
|
2157
|
+
// stays apart from every registry entry and joins only one of the same
|
|
2158
|
+
// source, so its importers are never credited with the registry's release
|
|
2159
|
+
// and a registry pin never turns it into a fetch.
|
|
2160
|
+
const accs = new Map();
|
|
2161
|
+
let seq = 0;
|
|
2162
|
+
const note = (eco, r, why) => {
|
|
2163
|
+
const key = `${eco}:${r.name}`;
|
|
2164
|
+
const same = accs.get(key) ?? [];
|
|
2165
|
+
const registry = same.filter((x) => !isUnfetchable(x));
|
|
2166
|
+
const anyRange = (range) => range === undefined || range === "*";
|
|
2167
|
+
const shared = (x) => anyRange(x.range) || anyRange(r.range) || x.range === r.range;
|
|
2168
|
+
// A resolved version joins an unresolved range only where that range was
|
|
2169
|
+
// the one resolved, or admits the version.
|
|
2170
|
+
const admits = (range, version) => anyRange(range) ||
|
|
2171
|
+
(eco === "npm" ? satisfiesRange(version, range) : pyVersionMatches(version, range));
|
|
2172
|
+
const compatible = (x) => x.version === null && r.version === null
|
|
2173
|
+
? shared(x)
|
|
2174
|
+
: x.range === r.range ||
|
|
2175
|
+
(x.version === null ? admits(x.range, r.version) : admits(r.range, x.version));
|
|
2176
|
+
let a = isUnfetchable(r)
|
|
2177
|
+
? same.find((x) => isUnfetchable(x) && x.range === r.range)
|
|
2178
|
+
: r.version === null
|
|
2179
|
+
? registry.find(compatible)
|
|
2180
|
+
: (registry.find((x) => x.version === r.version) ??
|
|
2181
|
+
registry.find((x) => x.version === null && compatible(x)));
|
|
2182
|
+
if (!a) {
|
|
2183
|
+
a = {
|
|
2184
|
+
ecosystem: eco,
|
|
2185
|
+
name: r.name,
|
|
2186
|
+
version: r.version,
|
|
2187
|
+
manifestWhys: [],
|
|
2188
|
+
importers: [],
|
|
2189
|
+
seq: seq++,
|
|
2190
|
+
};
|
|
2191
|
+
if (r.range !== undefined)
|
|
2192
|
+
a.range = r.range;
|
|
2193
|
+
same.push(a);
|
|
2194
|
+
accs.set(key, same);
|
|
2195
|
+
}
|
|
2196
|
+
else {
|
|
2197
|
+
// A `*` or range-less unresolved entry a specific range joins — or a
|
|
2198
|
+
// resolution fills in — is narrowed to that range, so a `*` seen first
|
|
2199
|
+
// never bridges two incompatible ranges.
|
|
2200
|
+
if (a.version === null && !anyRange(r.range) && anyRange(a.range))
|
|
2201
|
+
a.range = r.range;
|
|
2202
|
+
if (a.version === null && r.version !== null)
|
|
2203
|
+
a.version = r.version;
|
|
2204
|
+
}
|
|
2205
|
+
if (why.manifest && !a.manifestWhys.includes(why.manifest))
|
|
2206
|
+
a.manifestWhys.push(why.manifest);
|
|
2207
|
+
if (why.importer && !a.importers.includes(why.importer))
|
|
2208
|
+
a.importers.push(why.importer);
|
|
2209
|
+
};
|
|
2210
|
+
const live = [];
|
|
2211
|
+
for (const f of changedFiles) {
|
|
2212
|
+
const rel = normalizeRel(f.filename);
|
|
2213
|
+
if (rel && f.status !== "removed")
|
|
2214
|
+
live.push({ ...f, rel });
|
|
2215
|
+
}
|
|
2216
|
+
// 1. Manifest edits — the strongest signal: the PR chose this library.
|
|
2217
|
+
for (const f of live) {
|
|
2218
|
+
if (!f.patch)
|
|
2219
|
+
continue;
|
|
2220
|
+
const base = posix.basename(f.rel);
|
|
2221
|
+
if (base === "package.json") {
|
|
2222
|
+
for (const e of npmManifestEdits(f.patch, io.read(f.rel, MAX_MANIFEST_BYTES))) {
|
|
2223
|
+
const why = editWhy(e, f.rel);
|
|
2224
|
+
if (e.range !== null && isLocalRange(e.range))
|
|
2225
|
+
continue;
|
|
2226
|
+
const r = resolver.npm(e.name, f.rel);
|
|
2227
|
+
if (r === "first-party")
|
|
2228
|
+
continue;
|
|
2229
|
+
// The diff itself declares it, so an unresolvable lookup (unreadable
|
|
2230
|
+
// head file) still has the patch's range to go on.
|
|
2231
|
+
note("npm", r ?? { name: e.name, version: null, range: e.range ?? "*" }, { manifest: why });
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
else if (base === "pyproject.toml" || isRequirementsFile(f.rel)) {
|
|
2235
|
+
for (const e of pythonManifestEdits(f.rel, f.patch, io.read(f.rel, MAX_MANIFEST_BYTES))) {
|
|
2236
|
+
const why = editWhy(e, f.rel);
|
|
2237
|
+
const r = resolver.pyDist(e.name, f.rel);
|
|
2238
|
+
if (r === "first-party" || (e.range !== null && isLocalRange(e.range)))
|
|
2239
|
+
continue;
|
|
2240
|
+
note("pypi", r ?? { name: e.name, version: null, range: e.range ?? "*" }, {
|
|
2241
|
+
manifest: why,
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
// 2. Imports of the changed files, read whole at head (not just the patch:
|
|
2247
|
+
// a changed line calling `detect()` matters because of an import above it).
|
|
2248
|
+
for (const f of live.slice(0, MAX_CHANGED_FILES)) {
|
|
2249
|
+
const ext = posix.extname(f.rel).toLowerCase();
|
|
2250
|
+
const isJs = JS_EXTS.has(ext);
|
|
2251
|
+
if (!isJs && !PY_EXTS.has(ext))
|
|
2252
|
+
continue;
|
|
2253
|
+
const text = io.read(f.rel, MAX_SOURCE_FILE_BYTES);
|
|
2254
|
+
if (text === null)
|
|
2255
|
+
continue;
|
|
2256
|
+
if (isJs) {
|
|
2257
|
+
const names = new Set();
|
|
2258
|
+
for (const spec of extractJsImports(text)) {
|
|
2259
|
+
const n = npmPackageName(spec);
|
|
2260
|
+
if (n)
|
|
2261
|
+
names.add(n);
|
|
2262
|
+
}
|
|
2263
|
+
for (const n of names) {
|
|
2264
|
+
const r = resolver.npm(n, f.rel);
|
|
2265
|
+
if (r && r !== "first-party")
|
|
2266
|
+
note("npm", r, { importer: f.rel });
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
else {
|
|
2270
|
+
for (const imp of extractPythonImports(text))
|
|
2271
|
+
for (const r of resolver.py(imp, f.rel))
|
|
2272
|
+
note("pypi", r, { importer: f.rel });
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
return [...accs.values()]
|
|
2276
|
+
.flat()
|
|
2277
|
+
.sort((a, b) => {
|
|
2278
|
+
const am = a.manifestWhys.length > 0;
|
|
2279
|
+
const bm = b.manifestWhys.length > 0;
|
|
2280
|
+
if (am !== bm)
|
|
2281
|
+
return am ? -1 : 1;
|
|
2282
|
+
if (am)
|
|
2283
|
+
return a.seq - b.seq;
|
|
2284
|
+
return (b.importers.length - a.importers.length ||
|
|
2285
|
+
a.name.localeCompare(b.name) ||
|
|
2286
|
+
a.ecosystem.localeCompare(b.ecosystem));
|
|
2287
|
+
})
|
|
2288
|
+
.map((a) => {
|
|
2289
|
+
const req = {
|
|
2290
|
+
ecosystem: a.ecosystem,
|
|
2291
|
+
name: a.name,
|
|
2292
|
+
version: a.version,
|
|
2293
|
+
why: renderWhy(a),
|
|
2294
|
+
};
|
|
2295
|
+
if (a.range !== undefined)
|
|
2296
|
+
req.range = a.range;
|
|
2297
|
+
return req;
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
function cstr(b) {
|
|
2301
|
+
const i = b.indexOf(0);
|
|
2302
|
+
return b.subarray(0, i < 0 ? b.length : i).toString("utf8");
|
|
2303
|
+
}
|
|
2304
|
+
function tarNumber(field) {
|
|
2305
|
+
const first = field[0] ?? 0;
|
|
2306
|
+
if (first & 0x80) {
|
|
2307
|
+
// GNU base-256 for sizes past 8 GiB octal — positive only for sizes.
|
|
2308
|
+
let n = first & 0x7f;
|
|
2309
|
+
for (let i = 1; i < field.length; i++)
|
|
2310
|
+
n = n * 256 + (field[i] ?? 0);
|
|
2311
|
+
return n;
|
|
2312
|
+
}
|
|
2313
|
+
const s = cstr(field).trim();
|
|
2314
|
+
if (!s)
|
|
2315
|
+
return 0;
|
|
2316
|
+
if (!/^[0-7]+$/.test(s))
|
|
2317
|
+
throw new Error("corrupt tar archive (bad numeric field)");
|
|
2318
|
+
return parseInt(s, 8);
|
|
2319
|
+
}
|
|
2320
|
+
function tarChecksumOk(h) {
|
|
2321
|
+
const stored = cstr(h.subarray(148, 156)).trim();
|
|
2322
|
+
if (!/^[0-7]+$/.test(stored))
|
|
2323
|
+
return false;
|
|
2324
|
+
const want = parseInt(stored, 8);
|
|
2325
|
+
let unsigned = 0;
|
|
2326
|
+
let signed = 0;
|
|
2327
|
+
for (let i = 0; i < 512; i++) {
|
|
2328
|
+
const b = i >= 148 && i < 156 ? 32 : (h[i] ?? 0);
|
|
2329
|
+
unsigned += b;
|
|
2330
|
+
signed += b > 127 ? b - 256 : b;
|
|
2331
|
+
}
|
|
2332
|
+
return want === unsigned || want === signed;
|
|
2333
|
+
}
|
|
2334
|
+
function paxRecords(data) {
|
|
2335
|
+
const out = new Map();
|
|
2336
|
+
let i = 0;
|
|
2337
|
+
while (i < data.length) {
|
|
2338
|
+
const sp = data.indexOf(0x20, i);
|
|
2339
|
+
if (sp < 0)
|
|
2340
|
+
break;
|
|
2341
|
+
// `<len> <key>=<value>\n`: the length counts its own digits, the space and
|
|
2342
|
+
// the newline, so a shorter one is malformed. Accepting it would let a run
|
|
2343
|
+
// of zeroes "advance" one byte per record, re-scanning the rest each time.
|
|
2344
|
+
if (sp - i > 20 || !/^\d+$/.test(data.subarray(i, sp).toString("latin1")))
|
|
2345
|
+
break;
|
|
2346
|
+
const len = parseInt(data.subarray(i, sp).toString("latin1"), 10);
|
|
2347
|
+
if (!Number.isFinite(len) || len < sp - i + 2 || i + len > data.length)
|
|
2348
|
+
break;
|
|
2349
|
+
const rec = data.subarray(sp + 1, i + len - 1).toString("utf8");
|
|
2350
|
+
const eq = rec.indexOf("=");
|
|
2351
|
+
if (eq > 0)
|
|
2352
|
+
out.set(rec.slice(0, eq), rec.slice(eq + 1));
|
|
2353
|
+
i += len;
|
|
2354
|
+
}
|
|
2355
|
+
return out;
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* A (decompressed) tar: ustar + GNU long names (`L`) + pax `path=`. Returns
|
|
2359
|
+
* every entry with its RAW path and kind; nothing here touches the disk, so
|
|
2360
|
+
* path safety and the regular-files-only rule are `unpackEntries`' job.
|
|
2361
|
+
*/
|
|
2362
|
+
export function parseTar(buf) {
|
|
2363
|
+
const out = [];
|
|
2364
|
+
let off = 0;
|
|
2365
|
+
let longName = null;
|
|
2366
|
+
let paxPath = null;
|
|
2367
|
+
while (off + 512 <= buf.length) {
|
|
2368
|
+
const h = buf.subarray(off, off + 512);
|
|
2369
|
+
if (h.every((b) => b === 0))
|
|
2370
|
+
break;
|
|
2371
|
+
if (!tarChecksumOk(h))
|
|
2372
|
+
throw new Error(`corrupt tar archive (bad header checksum at byte ${off})`);
|
|
2373
|
+
const size = tarNumber(h.subarray(124, 136));
|
|
2374
|
+
const type = String.fromCharCode(h[156] ?? 0);
|
|
2375
|
+
const start = off + 512;
|
|
2376
|
+
const end = start + size;
|
|
2377
|
+
if (end > buf.length)
|
|
2378
|
+
throw new Error("corrupt tar archive (entry runs past the end)");
|
|
2379
|
+
const data = buf.subarray(start, end);
|
|
2380
|
+
off = start + Math.ceil(size / 512) * 512;
|
|
2381
|
+
if (type === "L") {
|
|
2382
|
+
longName = cstr(data);
|
|
2383
|
+
continue;
|
|
2384
|
+
}
|
|
2385
|
+
if (type === "x") {
|
|
2386
|
+
const p = paxRecords(data).get("path");
|
|
2387
|
+
if (p !== undefined)
|
|
2388
|
+
paxPath = p;
|
|
2389
|
+
continue;
|
|
2390
|
+
}
|
|
2391
|
+
if (type === "g" || type === "K")
|
|
2392
|
+
continue;
|
|
2393
|
+
let name = cstr(h.subarray(0, 100));
|
|
2394
|
+
// The prefix field is ustar's; GNU tars put other data at that offset.
|
|
2395
|
+
if (h.subarray(257, 263).toString("latin1") === "ustar\0") {
|
|
2396
|
+
const prefix = cstr(h.subarray(345, 500));
|
|
2397
|
+
if (prefix)
|
|
2398
|
+
name = `${prefix}/${name}`;
|
|
2399
|
+
}
|
|
2400
|
+
const path = paxPath ?? longName ?? name;
|
|
2401
|
+
paxPath = null;
|
|
2402
|
+
longName = null;
|
|
2403
|
+
const kind = type === "0" || type === "\0"
|
|
2404
|
+
? path.endsWith("/")
|
|
2405
|
+
? "dir"
|
|
2406
|
+
: "file"
|
|
2407
|
+
: type === "5"
|
|
2408
|
+
? "dir"
|
|
2409
|
+
: type === "2"
|
|
2410
|
+
? "symlink"
|
|
2411
|
+
: type === "1"
|
|
2412
|
+
? "hardlink"
|
|
2413
|
+
: "other";
|
|
2414
|
+
out.push({ path, kind, size, read: () => data });
|
|
2415
|
+
}
|
|
2416
|
+
return out;
|
|
2417
|
+
}
|
|
2418
|
+
function findEocd(buf) {
|
|
2419
|
+
const min = Math.max(0, buf.length - 22 - 0xffff);
|
|
2420
|
+
for (let i = buf.length - 22; i >= min; i--)
|
|
2421
|
+
if (buf.readUInt32LE(i) === 0x06054b50)
|
|
2422
|
+
return i;
|
|
2423
|
+
return -1;
|
|
2424
|
+
}
|
|
2425
|
+
function readZipEntry(buf, local, method, csize, usize, crc) {
|
|
2426
|
+
if (local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50)
|
|
2427
|
+
throw new Error("corrupt zip archive (bad local header)");
|
|
2428
|
+
// Only the local header's name/extra LENGTHS are read from it; sizes come
|
|
2429
|
+
// from the central directory, which is what every sane reader trusts.
|
|
2430
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
|
2431
|
+
const end = start + csize;
|
|
2432
|
+
if (end > buf.length)
|
|
2433
|
+
throw new Error("corrupt zip archive (entry runs past the end)");
|
|
2434
|
+
const raw = buf.subarray(start, end);
|
|
2435
|
+
let data;
|
|
2436
|
+
if (method === 0) {
|
|
2437
|
+
if (csize !== usize)
|
|
2438
|
+
throw new Error("corrupt zip archive (stored size mismatch)");
|
|
2439
|
+
data = Buffer.from(raw);
|
|
2440
|
+
}
|
|
2441
|
+
else {
|
|
2442
|
+
// maxOutputLength is the zip-bomb stop: inflation halts at the declared size.
|
|
2443
|
+
data = usize === 0 ? Buffer.alloc(0) : zlib.inflateRawSync(raw, { maxOutputLength: usize });
|
|
2444
|
+
}
|
|
2445
|
+
if (data.length !== usize)
|
|
2446
|
+
throw new Error("corrupt zip archive (size mismatch)");
|
|
2447
|
+
if (zlib.crc32(data) >>> 0 !== crc >>> 0)
|
|
2448
|
+
throw new Error("corrupt zip archive (CRC mismatch)");
|
|
2449
|
+
return data;
|
|
2450
|
+
}
|
|
2451
|
+
/** A zip (wheel, zip sdist) via its central directory. zip64 is refused. */
|
|
2452
|
+
export function parseZip(buf) {
|
|
2453
|
+
const eocd = findEocd(buf);
|
|
2454
|
+
if (eocd < 0)
|
|
2455
|
+
throw new Error("corrupt zip archive (no end-of-central-directory record)");
|
|
2456
|
+
const total = buf.readUInt16LE(eocd + 10);
|
|
2457
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
2458
|
+
const cdOff = buf.readUInt32LE(eocd + 16);
|
|
2459
|
+
if (total === 0xffff || cdSize === 0xffffffff || cdOff === 0xffffffff)
|
|
2460
|
+
throw new Error("zip64 archives are not supported");
|
|
2461
|
+
if (cdOff + cdSize > buf.length)
|
|
2462
|
+
throw new Error("corrupt zip archive (central directory out of bounds)");
|
|
2463
|
+
const out = [];
|
|
2464
|
+
// Entries of a real zip occupy disjoint ranges, so the compressed bytes read
|
|
2465
|
+
// across all of them never exceed the archive. Central-directory entries that
|
|
2466
|
+
// point at one shared payload would multiply the inflate work per name —
|
|
2467
|
+
// unbounded, synchronous CPU no output cap sees — so that is refused.
|
|
2468
|
+
let consumed = 0;
|
|
2469
|
+
let p = cdOff;
|
|
2470
|
+
for (let i = 0; i < total; i++) {
|
|
2471
|
+
if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50)
|
|
2472
|
+
throw new Error("corrupt zip archive (bad central directory entry)");
|
|
2473
|
+
const madeBy = buf.readUInt16LE(p + 4);
|
|
2474
|
+
const flags = buf.readUInt16LE(p + 8);
|
|
2475
|
+
const method = buf.readUInt16LE(p + 10);
|
|
2476
|
+
const crc = buf.readUInt32LE(p + 16);
|
|
2477
|
+
const csize = buf.readUInt32LE(p + 20);
|
|
2478
|
+
const usize = buf.readUInt32LE(p + 24);
|
|
2479
|
+
const n = buf.readUInt16LE(p + 28);
|
|
2480
|
+
const e = buf.readUInt16LE(p + 30);
|
|
2481
|
+
const c = buf.readUInt16LE(p + 32);
|
|
2482
|
+
const ext = buf.readUInt32LE(p + 38);
|
|
2483
|
+
const local = buf.readUInt32LE(p + 42);
|
|
2484
|
+
if (p + 46 + n > buf.length)
|
|
2485
|
+
throw new Error("corrupt zip archive (name out of bounds)");
|
|
2486
|
+
const path = buf.subarray(p + 46, p + 46 + n).toString("utf8");
|
|
2487
|
+
p += 46 + n + e + c;
|
|
2488
|
+
const mode = madeBy >> 8 === 3 ? (ext >>> 16) & 0o170000 : 0;
|
|
2489
|
+
let kind;
|
|
2490
|
+
if (path.endsWith("/") || mode === 0o040000)
|
|
2491
|
+
kind = "dir";
|
|
2492
|
+
else if (mode === 0o120000)
|
|
2493
|
+
kind = "symlink";
|
|
2494
|
+
else if (mode !== 0 && mode !== 0o100000)
|
|
2495
|
+
kind = "other";
|
|
2496
|
+
else if (flags & 1 || (method !== 0 && method !== 8))
|
|
2497
|
+
kind = "other"; // encrypted / unsupported method
|
|
2498
|
+
else
|
|
2499
|
+
kind = "file";
|
|
2500
|
+
out.push({
|
|
2501
|
+
path,
|
|
2502
|
+
kind,
|
|
2503
|
+
size: usize,
|
|
2504
|
+
read: () => {
|
|
2505
|
+
consumed += csize;
|
|
2506
|
+
if (consumed > buf.length)
|
|
2507
|
+
throw new Error("corrupt zip archive (entries overlap one another)");
|
|
2508
|
+
return readZipEntry(buf, local, method, csize, usize, crc);
|
|
2509
|
+
},
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
return out;
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* An archive entry path → safe relative segments, or null. Rejects absolute
|
|
2516
|
+
* paths, drive letters, `..` anywhere, NUL bytes and backslashes outright —
|
|
2517
|
+
* rejected, not "fixed", because a registry archive carrying one of them is
|
|
2518
|
+
* not an archive whose other paths deserve the benefit of the doubt.
|
|
2519
|
+
*/
|
|
2520
|
+
export function safeArchivePath(raw) {
|
|
2521
|
+
if (!raw || raw.includes("\0") || raw.includes("\\"))
|
|
2522
|
+
return null;
|
|
2523
|
+
if (raw.startsWith("/") || /^[A-Za-z]:/.test(raw))
|
|
2524
|
+
return null;
|
|
2525
|
+
const segs = raw.split("/").filter((s) => s !== "" && s !== ".");
|
|
2526
|
+
if (!segs.length || segs.includes(".."))
|
|
2527
|
+
return null;
|
|
2528
|
+
return segs;
|
|
2529
|
+
}
|
|
2530
|
+
const KEEP_EXTS = new Set([
|
|
2531
|
+
".js",
|
|
2532
|
+
".mjs",
|
|
2533
|
+
".cjs",
|
|
2534
|
+
".ts",
|
|
2535
|
+
".mts",
|
|
2536
|
+
".cts",
|
|
2537
|
+
".jsx",
|
|
2538
|
+
".tsx",
|
|
2539
|
+
".json",
|
|
2540
|
+
".py",
|
|
2541
|
+
".pyi",
|
|
2542
|
+
".md",
|
|
2543
|
+
".txt",
|
|
2544
|
+
".toml",
|
|
2545
|
+
".cfg",
|
|
2546
|
+
".ini",
|
|
2547
|
+
".yml",
|
|
2548
|
+
".yaml",
|
|
2549
|
+
]);
|
|
2550
|
+
const SKIP_DIRS = new Set(["node_modules", "test", "tests", "__tests__", "docs", "examples"]);
|
|
2551
|
+
/** Is this (post-strip) path readable library source worth keeping? */
|
|
2552
|
+
export function keepSourceFile(rel) {
|
|
2553
|
+
const segs = rel.split("/");
|
|
2554
|
+
const base = segs[segs.length - 1] ?? "";
|
|
2555
|
+
if (segs.slice(0, -1).some((d) => SKIP_DIRS.has(d.toLowerCase())))
|
|
2556
|
+
return false;
|
|
2557
|
+
const lower = base.toLowerCase();
|
|
2558
|
+
if (lower.endsWith(".min.js") || lower.endsWith(".map"))
|
|
2559
|
+
return false;
|
|
2560
|
+
const dot = lower.lastIndexOf(".");
|
|
2561
|
+
if (dot <= 0)
|
|
2562
|
+
return /^(license|licence|readme)$/.test(lower);
|
|
2563
|
+
return KEEP_EXTS.has(lower.slice(dot));
|
|
2564
|
+
}
|
|
2565
|
+
/**
|
|
2566
|
+
* Write the kept REGULAR files of an archive under `targetDir`. Symlinks,
|
|
2567
|
+
* hardlinks, devices and directories-as-entries are never materialised (dirs
|
|
2568
|
+
* are implied by files), so no write can be redirected through a link.
|
|
2569
|
+
* Throws past `maxUnpackedBytes` — the caller removes the partial dir.
|
|
2570
|
+
*/
|
|
2571
|
+
export function unpackEntries(entries, targetDir, opts) {
|
|
2572
|
+
const target = resolve(targetDir);
|
|
2573
|
+
let rejected = 0;
|
|
2574
|
+
const candidates = [];
|
|
2575
|
+
for (const e of entries) {
|
|
2576
|
+
const segs = safeArchivePath(e.path);
|
|
2577
|
+
if (!segs) {
|
|
2578
|
+
rejected++;
|
|
2579
|
+
continue;
|
|
2580
|
+
}
|
|
2581
|
+
if (e.kind === "file")
|
|
2582
|
+
candidates.push({ segs, e });
|
|
2583
|
+
}
|
|
2584
|
+
let strip = 0;
|
|
2585
|
+
if (opts.strip === "first")
|
|
2586
|
+
strip = 1;
|
|
2587
|
+
else if (opts.strip === "common-top" && candidates.length) {
|
|
2588
|
+
const top = candidates[0]?.segs[0];
|
|
2589
|
+
if (candidates.every((c) => c.segs.length > 1 && c.segs[0] === top))
|
|
2590
|
+
strip = 1;
|
|
2591
|
+
}
|
|
2592
|
+
// Later duplicates win, as with any tar extractor.
|
|
2593
|
+
const chosen = new Map();
|
|
2594
|
+
for (const { segs, e } of candidates) {
|
|
2595
|
+
const rel = segs.slice(strip).join("/");
|
|
2596
|
+
if (!rel)
|
|
2597
|
+
continue;
|
|
2598
|
+
if (keepSourceFile(rel) && e.size <= MAX_KEPT_FILE_BYTES)
|
|
2599
|
+
chosen.set(rel, e);
|
|
2600
|
+
else
|
|
2601
|
+
chosen.delete(rel);
|
|
2602
|
+
}
|
|
2603
|
+
let files = 0;
|
|
2604
|
+
let bytes = 0;
|
|
2605
|
+
let inflated = 0;
|
|
2606
|
+
const maxFiles = opts.maxFiles ?? MAX_KEPT_FILES;
|
|
2607
|
+
const maxDirs = opts.maxDirs ?? MAX_KEPT_FILES;
|
|
2608
|
+
const maxInflated = opts.maxInflatedBytes ?? MAX_EXPANDED_ARCHIVE_BYTES;
|
|
2609
|
+
// Every directory a kept path implies is an inode too: capped BEFORE it is
|
|
2610
|
+
// created, so a few files under deep, distinct chains cannot spend them
|
|
2611
|
+
// unseen; reported (and charged to the cache) only once it exists.
|
|
2612
|
+
const dirs = new Set();
|
|
2613
|
+
const created = new Set();
|
|
2614
|
+
for (const [rel, e] of chosen) {
|
|
2615
|
+
if (files >= maxFiles)
|
|
2616
|
+
throw new SkipError(`archive holds more than ${maxFiles} files`);
|
|
2617
|
+
const dest = resolve(target, rel);
|
|
2618
|
+
if (!dest.startsWith(target + sep)) {
|
|
2619
|
+
rejected++;
|
|
2620
|
+
continue;
|
|
2621
|
+
}
|
|
2622
|
+
for (let d = posix.dirname(rel); d !== "." && !dirs.has(d); d = posix.dirname(d)) {
|
|
2623
|
+
dirs.add(d);
|
|
2624
|
+
if (dirs.size > maxDirs)
|
|
2625
|
+
throw new SkipError(`archive creates more than ${maxDirs} directories`);
|
|
2626
|
+
}
|
|
2627
|
+
// The directory first: a path that clashes with a kept file fails here,
|
|
2628
|
+
// before its entry is inflated.
|
|
2629
|
+
try {
|
|
2630
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
2631
|
+
}
|
|
2632
|
+
catch (err) {
|
|
2633
|
+
if (!isNameClash(err))
|
|
2634
|
+
throw err;
|
|
2635
|
+
continue; // a file/dir name clash inside the archive: that one entry is lost
|
|
2636
|
+
}
|
|
2637
|
+
for (let d = posix.dirname(rel); d !== "." && !created.has(d); d = posix.dirname(d))
|
|
2638
|
+
created.add(d);
|
|
2639
|
+
const data = e.read();
|
|
2640
|
+
// Charged whether or not the write lands: a failed write must not make
|
|
2641
|
+
// the decompression free.
|
|
2642
|
+
inflated += data.length;
|
|
2643
|
+
if (inflated > maxInflated)
|
|
2644
|
+
throw new SkipError(`archive expands past ${fmtBytes(maxInflated)}`);
|
|
2645
|
+
if (data.length > MAX_KEPT_FILE_BYTES)
|
|
2646
|
+
continue;
|
|
2647
|
+
if (bytes + data.length > opts.maxUnpackedBytes)
|
|
2648
|
+
throw new SkipError(`unpacked source exceeds ${fmtBytes(opts.maxUnpackedBytes)}`);
|
|
2649
|
+
try {
|
|
2650
|
+
writeFileSync(dest, data, { mode: 0o644 });
|
|
2651
|
+
files++;
|
|
2652
|
+
bytes += data.length;
|
|
2653
|
+
}
|
|
2654
|
+
catch (err) {
|
|
2655
|
+
// A file/dir name clash inside the archive: that one entry is lost.
|
|
2656
|
+
if (!isNameClash(err))
|
|
2657
|
+
throw err;
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
return { files, bytes, dirs: created.size, rejected };
|
|
2661
|
+
}
|
|
2662
|
+
/** A file/dir name clash, or a name too long for the filesystem, inside one
|
|
2663
|
+
* archive — the only filesystem failures an unpack shrugs off (that one entry
|
|
2664
|
+
* is lost). Anything else (no space, no permission, an I/O error) fails the
|
|
2665
|
+
* unpack, so a partial copy is never marked complete. */
|
|
2666
|
+
function isNameClash(err) {
|
|
2667
|
+
const code = err?.code;
|
|
2668
|
+
return code === "EEXIST" || code === "ENOTDIR" || code === "EISDIR" || code === "ENAMETOOLONG";
|
|
2669
|
+
}
|
|
2670
|
+
function gunzipCapped(buf) {
|
|
2671
|
+
try {
|
|
2672
|
+
return zlib.gunzipSync(buf, { maxOutputLength: MAX_EXPANDED_ARCHIVE_BYTES });
|
|
2673
|
+
}
|
|
2674
|
+
catch (e) {
|
|
2675
|
+
if (e instanceof RangeError || e.code === "ERR_BUFFER_TOO_LARGE") {
|
|
2676
|
+
throw new SkipError(`archive expands past ${fmtBytes(MAX_EXPANDED_ARCHIVE_BYTES)}`);
|
|
2677
|
+
}
|
|
2678
|
+
throw new SkipError("corrupt archive (gzip)");
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
/** A version safe as a path component: PEP 440's epoch `!` (`1!2.0`) included. */
|
|
2682
|
+
const SAFE_VERSION_RE = /^[0-9A-Za-z][0-9A-Za-z.+_!-]*$/;
|
|
2683
|
+
/** `<root>/<eco>/<name, scope slash → +>@<version>`, or null when either part is unsafe as a path.
|
|
2684
|
+
* `+` is in neither npm's nor PyPI's name alphabet, so the mapping is
|
|
2685
|
+
* one-to-one (a `__` separator would not be: `@a/b__c` and `@a__b/c` would
|
|
2686
|
+
* share a directory). */
|
|
2687
|
+
function cacheDirFor(root, eco, name, version) {
|
|
2688
|
+
if (!SAFE_VERSION_RE.test(version) || version.includes(".."))
|
|
2689
|
+
return null;
|
|
2690
|
+
// A literal `+` would let `@a+b` alias `@a/b`; neither registry allows one.
|
|
2691
|
+
if (name.includes("+"))
|
|
2692
|
+
return null;
|
|
2693
|
+
const flat = flatPackageName(name);
|
|
2694
|
+
if (!/^@?[A-Za-z0-9][A-Za-z0-9._~-]*(?:\+[A-Za-z0-9._~-]+)?$/.test(flat) || flat.includes(".."))
|
|
2695
|
+
return null;
|
|
2696
|
+
return join(root, eco, cacheEntryName(name, version));
|
|
2697
|
+
}
|
|
2698
|
+
/** A cache entry's directory name. */
|
|
2699
|
+
function cacheEntryName(name, version) {
|
|
2700
|
+
return `${flatPackageName(name)}@${version}`;
|
|
2701
|
+
}
|
|
2702
|
+
/** A package name as one path component: `@s/p` → `@s+p`. */
|
|
2703
|
+
function flatPackageName(name) {
|
|
2704
|
+
return name.replace("/", "+");
|
|
2705
|
+
}
|
|
2706
|
+
function readMarker(dir) {
|
|
2707
|
+
try {
|
|
2708
|
+
const m = JSON.parse(readFileSync(join(dir, MARKER), "utf8"));
|
|
2709
|
+
if (isObj(m) && typeof m.files === "number" && typeof m.bytes === "number") {
|
|
2710
|
+
return {
|
|
2711
|
+
name: str(m.name) ?? "",
|
|
2712
|
+
version: str(m.version) ?? "",
|
|
2713
|
+
url: str(m.url) ?? "",
|
|
2714
|
+
sha256: str(m.sha256) ?? "",
|
|
2715
|
+
files: m.files,
|
|
2716
|
+
bytes: m.bytes,
|
|
2717
|
+
dirs: typeof m.dirs === "number" ? m.dirs : 0,
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
catch {
|
|
2722
|
+
// missing or torn marker: not complete
|
|
2723
|
+
}
|
|
2724
|
+
return null;
|
|
2725
|
+
}
|
|
2726
|
+
/** An install's private unpack dir: `<package dir>.tmp-<hex>`. */
|
|
2727
|
+
const INSTALL_TMP_RE = /\.tmp-[0-9a-f]+$/;
|
|
2728
|
+
/** Remove the install tmp dirs in `parent` (only those of `owner`, a package
|
|
2729
|
+
* dir's name, when given) that a crashed run left — older than an hour. A
|
|
2730
|
+
* live install's dir is minutes old at most. */
|
|
2731
|
+
function sweepStaleTmp(parent, owner) {
|
|
2732
|
+
try {
|
|
2733
|
+
for (const f of readdirSync(parent)) {
|
|
2734
|
+
if (!INSTALL_TMP_RE.test(f))
|
|
2735
|
+
continue;
|
|
2736
|
+
if (owner !== undefined && !f.startsWith(`${owner}.tmp-`))
|
|
2737
|
+
continue;
|
|
2738
|
+
try {
|
|
2739
|
+
const p = join(parent, f);
|
|
2740
|
+
// A complete entry (a version that merely ends like a tmp name) stays;
|
|
2741
|
+
// a marked dir under any other name is an install that crashed between
|
|
2742
|
+
// its marker and its rename.
|
|
2743
|
+
const m = readMarker(p);
|
|
2744
|
+
if (m && f === cacheEntryName(m.name, m.version))
|
|
2745
|
+
continue;
|
|
2746
|
+
if (Date.now() - statSync(p).mtimeMs > STALE_TMP_MS)
|
|
2747
|
+
rmSync(p, { recursive: true, force: true });
|
|
2748
|
+
}
|
|
2749
|
+
catch {
|
|
2750
|
+
// vanished, or not ours to remove: the next one
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
catch {
|
|
2755
|
+
// best effort
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
/** Every ecosystem dir's stale install tmp dirs, whatever package left them:
|
|
2759
|
+
* a run killed mid-unpack whose package is then served from the cache never
|
|
2760
|
+
* installs again, so its own sweep would never come. */
|
|
2761
|
+
export function sweepStaleInstallDirs(root) {
|
|
2762
|
+
let ecos = [];
|
|
2763
|
+
try {
|
|
2764
|
+
ecos = readdirSync(root);
|
|
2765
|
+
}
|
|
2766
|
+
catch {
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
for (const eco of ecos)
|
|
2770
|
+
sweepStaleTmp(join(root, eco));
|
|
2771
|
+
}
|
|
2772
|
+
function allowedUrl(url, prefix) {
|
|
2773
|
+
try {
|
|
2774
|
+
const u = new URL(url);
|
|
2775
|
+
const p = new URL(prefix);
|
|
2776
|
+
return (u.protocol === "https:" &&
|
|
2777
|
+
u.hostname === p.hostname &&
|
|
2778
|
+
!u.port &&
|
|
2779
|
+
!u.username &&
|
|
2780
|
+
!u.password &&
|
|
2781
|
+
u.href.startsWith(prefix));
|
|
2782
|
+
}
|
|
2783
|
+
catch {
|
|
2784
|
+
return false;
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
async function readCapped(res, max, what) {
|
|
2788
|
+
if (!res.body) {
|
|
2789
|
+
const b = Buffer.from(await res.arrayBuffer());
|
|
2790
|
+
if (b.length > max)
|
|
2791
|
+
throw new SkipError(`${what} exceeds ${fmtBytes(max)}`);
|
|
2792
|
+
return b;
|
|
2793
|
+
}
|
|
2794
|
+
const reader = res.body.getReader();
|
|
2795
|
+
const chunks = [];
|
|
2796
|
+
let n = 0;
|
|
2797
|
+
for (;;) {
|
|
2798
|
+
const { done, value } = await reader.read();
|
|
2799
|
+
if (done)
|
|
2800
|
+
break;
|
|
2801
|
+
n += value.byteLength;
|
|
2802
|
+
if (n > max) {
|
|
2803
|
+
await reader.cancel().catch(() => undefined);
|
|
2804
|
+
throw new SkipError(`${what} exceeds ${fmtBytes(max)}`);
|
|
2805
|
+
}
|
|
2806
|
+
chunks.push(Buffer.from(value));
|
|
2807
|
+
}
|
|
2808
|
+
return Buffer.concat(chunks, n);
|
|
2809
|
+
}
|
|
2810
|
+
/**
|
|
2811
|
+
* One bounded GET: per-request timeout, the overall deadline, a byte cap
|
|
2812
|
+
* enforced on content-length AND on the bytes actually read, and a check that
|
|
2813
|
+
* any redirect stayed on the allowed host.
|
|
2814
|
+
*/
|
|
2815
|
+
async function httpGet(ctx, url, allowedPrefix, maxBytes, what, accept) {
|
|
2816
|
+
const remaining = ctx.deadline - Date.now();
|
|
2817
|
+
if (remaining <= 0)
|
|
2818
|
+
throw new SkipError("time budget");
|
|
2819
|
+
const ac = new AbortController();
|
|
2820
|
+
const timer = setTimeout(() => ac.abort(), Math.min(PER_REQUEST_TIMEOUT_MS, remaining));
|
|
2821
|
+
try {
|
|
2822
|
+
const headers = { "user-agent": USER_AGENT };
|
|
2823
|
+
if (accept)
|
|
2824
|
+
headers.accept = accept;
|
|
2825
|
+
const res = await ctx.fetch(url, { signal: ac.signal, headers, redirect: "follow" });
|
|
2826
|
+
const offPrefix = !!res.url && !res.url.startsWith(allowedPrefix);
|
|
2827
|
+
if (offPrefix || !res.ok) {
|
|
2828
|
+
// A rejected response is never consumed. Release its transfer before
|
|
2829
|
+
// clearing the request deadline; cleanup cannot delay preparation.
|
|
2830
|
+
void res.body?.cancel().catch(() => undefined);
|
|
2831
|
+
ac.abort();
|
|
2832
|
+
}
|
|
2833
|
+
if (offPrefix)
|
|
2834
|
+
throw new SkipError(`redirected off ${hostOf(allowedPrefix)}`);
|
|
2835
|
+
if (res.status === 404)
|
|
2836
|
+
throw new SkipError(`not found (404 from ${hostOf(url)})`);
|
|
2837
|
+
if (!res.ok)
|
|
2838
|
+
throw new SkipError(`HTTP ${res.status} from ${hostOf(url)}`);
|
|
2839
|
+
const len = Number(res.headers.get("content-length") ?? Number.NaN);
|
|
2840
|
+
if (Number.isFinite(len) && len > maxBytes) {
|
|
2841
|
+
await res.body?.cancel().catch(() => undefined);
|
|
2842
|
+
throw new SkipError(`${what} too large (${fmtBytes(len)} > ${fmtBytes(maxBytes)})`);
|
|
2843
|
+
}
|
|
2844
|
+
return await readCapped(res, maxBytes, what);
|
|
2845
|
+
}
|
|
2846
|
+
catch (e) {
|
|
2847
|
+
if (e instanceof SkipError)
|
|
2848
|
+
throw e;
|
|
2849
|
+
if (Date.now() >= ctx.deadline - 5)
|
|
2850
|
+
throw new SkipError("time budget");
|
|
2851
|
+
if (ac.signal.aborted)
|
|
2852
|
+
throw new SkipError(`request timed out (${hostOf(url)})`);
|
|
2853
|
+
throw new SkipError(`fetch failed: ${short(e)}`);
|
|
2854
|
+
}
|
|
2855
|
+
finally {
|
|
2856
|
+
clearTimeout(timer);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
async function getJson(ctx, url, allowedPrefix, accept) {
|
|
2860
|
+
const buf = await httpGet(ctx, url, allowedPrefix, METADATA_MAX_BYTES, "metadata", accept);
|
|
2861
|
+
let doc;
|
|
2862
|
+
try {
|
|
2863
|
+
doc = JSON.parse(buf.toString("utf8"));
|
|
2864
|
+
}
|
|
2865
|
+
catch {
|
|
2866
|
+
throw new SkipError(`unreadable metadata from ${hostOf(url)}`);
|
|
2867
|
+
}
|
|
2868
|
+
if (!isObj(doc))
|
|
2869
|
+
throw new SkipError(`unreadable metadata from ${hostOf(url)}`);
|
|
2870
|
+
return doc;
|
|
2871
|
+
}
|
|
2872
|
+
/** npm `dist.integrity` (SRI, strongest algorithm first) or legacy `dist.shasum`; null = nothing to check. */
|
|
2873
|
+
function verifyNpmIntegrity(buf, integrity, shasum) {
|
|
2874
|
+
if (integrity) {
|
|
2875
|
+
const byAlg = new Map();
|
|
2876
|
+
for (const tok of integrity.trim().split(/\s+/)) {
|
|
2877
|
+
const dash = tok.indexOf("-");
|
|
2878
|
+
if (dash > 0) {
|
|
2879
|
+
const alg = tok.slice(0, dash).toLowerCase();
|
|
2880
|
+
byAlg.set(alg, [
|
|
2881
|
+
...(byAlg.get(alg) ?? []),
|
|
2882
|
+
(tok.slice(dash + 1).split("?")[0] ?? "").trim(),
|
|
2883
|
+
]);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
for (const alg of ["sha512", "sha384", "sha256", "sha1"]) {
|
|
2887
|
+
const want = byAlg.get(alg)?.filter(Boolean);
|
|
2888
|
+
// SRI: any digest of the strongest algorithm present may match.
|
|
2889
|
+
if (want?.length)
|
|
2890
|
+
return want.includes(createHash(alg).update(buf).digest("base64"));
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
if (shasum)
|
|
2894
|
+
return createHash("sha1").update(buf).digest("hex") === shasum.toLowerCase();
|
|
2895
|
+
return null;
|
|
2896
|
+
}
|
|
2897
|
+
function sha256hex(buf) {
|
|
2898
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
2899
|
+
}
|
|
2900
|
+
function fromCache(ctx, req, name, version, range) {
|
|
2901
|
+
const final = cacheDirFor(ctx.root, req.ecosystem, name, version);
|
|
2902
|
+
if (!final)
|
|
2903
|
+
return null;
|
|
2904
|
+
const marker = readMarker(final);
|
|
2905
|
+
if (!marker)
|
|
2906
|
+
return null;
|
|
2907
|
+
// A hit refreshes the entry's age, so the size cap evicts least recently USED.
|
|
2908
|
+
try {
|
|
2909
|
+
const now = new Date();
|
|
2910
|
+
utimesSync(join(final, MARKER), now, now);
|
|
2911
|
+
}
|
|
2912
|
+
catch {
|
|
2913
|
+
// an unwritable marker only makes the entry look older
|
|
2914
|
+
}
|
|
2915
|
+
if (marker.files === 0) {
|
|
2916
|
+
return {
|
|
2917
|
+
ok: false,
|
|
2918
|
+
skip: {
|
|
2919
|
+
ecosystem: req.ecosystem,
|
|
2920
|
+
name,
|
|
2921
|
+
reason: `no readable source files in ${name}@${version}`,
|
|
2922
|
+
},
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
const source = {
|
|
2926
|
+
ecosystem: req.ecosystem,
|
|
2927
|
+
name,
|
|
2928
|
+
version,
|
|
2929
|
+
path: final,
|
|
2930
|
+
why: req.why,
|
|
2931
|
+
files: marker.files,
|
|
2932
|
+
bytes: marker.bytes,
|
|
2933
|
+
};
|
|
2934
|
+
if (range !== undefined)
|
|
2935
|
+
source.range = range;
|
|
2936
|
+
return { ok: true, source };
|
|
2937
|
+
}
|
|
2938
|
+
function install(ctx, req, name, version, url, archive, entries, strip, range) {
|
|
2939
|
+
const final = cacheDirFor(ctx.root, req.ecosystem, name, version);
|
|
2940
|
+
if (!final)
|
|
2941
|
+
throw new SkipError("unsafe package name or version for a cache path");
|
|
2942
|
+
const raced = fromCache(ctx, req, name, version, range);
|
|
2943
|
+
if (raced)
|
|
2944
|
+
return raced;
|
|
2945
|
+
mkdirSync(dirname(final), { recursive: true });
|
|
2946
|
+
sweepStaleTmp(dirname(final), basename(final));
|
|
2947
|
+
// Unpack into a private tmp dir and rename it into place: a reader either
|
|
2948
|
+
// sees no dir or a complete one, never a half-written package.
|
|
2949
|
+
const tmp = `${final}.tmp-${randomBytes(6).toString("hex")}`;
|
|
2950
|
+
try {
|
|
2951
|
+
mkdirSync(tmp);
|
|
2952
|
+
const r = unpackEntries(entries, tmp, { strip, maxUnpackedBytes: ctx.maxUnpackedBytes });
|
|
2953
|
+
const marker = {
|
|
2954
|
+
name,
|
|
2955
|
+
version,
|
|
2956
|
+
url,
|
|
2957
|
+
sha256: sha256hex(archive),
|
|
2958
|
+
files: r.files,
|
|
2959
|
+
bytes: r.bytes,
|
|
2960
|
+
dirs: r.dirs,
|
|
2961
|
+
};
|
|
2962
|
+
// The marker is written LAST: its presence is the completeness proof.
|
|
2963
|
+
writeFileSync(join(tmp, MARKER), `${JSON.stringify(marker)}\n`);
|
|
2964
|
+
try {
|
|
2965
|
+
renameSync(tmp, final);
|
|
2966
|
+
}
|
|
2967
|
+
catch {
|
|
2968
|
+
if (readMarker(final)) {
|
|
2969
|
+
rmSync(tmp, { recursive: true, force: true }); // lost a race to an identical copy
|
|
2970
|
+
}
|
|
2971
|
+
else {
|
|
2972
|
+
rmSync(final, { recursive: true, force: true }); // an incomplete leftover — replace it
|
|
2973
|
+
renameSync(tmp, final);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
catch (e) {
|
|
2978
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
2979
|
+
throw e;
|
|
2980
|
+
}
|
|
2981
|
+
const done = fromCache(ctx, req, name, version, range);
|
|
2982
|
+
if (!done)
|
|
2983
|
+
throw new SkipError("cache write did not complete");
|
|
2984
|
+
return done;
|
|
2985
|
+
}
|
|
2986
|
+
function encodeNpmName(name) {
|
|
2987
|
+
return name.startsWith("@") ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name);
|
|
2988
|
+
}
|
|
2989
|
+
async function prepareNpm(req, ctx) {
|
|
2990
|
+
const name = req.name;
|
|
2991
|
+
if (!NPM_NAME_RE.test(name))
|
|
2992
|
+
throw new SkipError("not a valid npm package name");
|
|
2993
|
+
rejectUnfetchable(req);
|
|
2994
|
+
let version = req.version;
|
|
2995
|
+
let resolvedFrom;
|
|
2996
|
+
if (version !== null) {
|
|
2997
|
+
const hit = fromCache(ctx, req, name, version);
|
|
2998
|
+
if (hit)
|
|
2999
|
+
return hit;
|
|
3000
|
+
}
|
|
3001
|
+
let vdoc;
|
|
3002
|
+
if (version !== null && !name.startsWith("@")) {
|
|
3003
|
+
vdoc = await getJson(ctx, `${NPM_REGISTRY}${encodeNpmName(name)}/${encodeURIComponent(version)}`, NPM_REGISTRY);
|
|
3004
|
+
}
|
|
3005
|
+
else {
|
|
3006
|
+
// Scoped packages always go through the packument: the per-version
|
|
3007
|
+
// endpoint has historically not served scoped names reliably.
|
|
3008
|
+
const pack = await getJson(ctx, `${NPM_REGISTRY}${encodeNpmName(name)}`, NPM_REGISTRY, NPM_ABBREVIATED);
|
|
3009
|
+
const versions = isObj(pack.versions) ? pack.versions : {};
|
|
3010
|
+
if (version === null) {
|
|
3011
|
+
const range = req.range ?? "*";
|
|
3012
|
+
const picked = pickNpmVersion(Object.keys(versions), range, isObj(pack["dist-tags"]) ? pack["dist-tags"] : {});
|
|
3013
|
+
if (!picked)
|
|
3014
|
+
throw new SkipError(`no published version matches ${clip(range, 40)}`);
|
|
3015
|
+
version = picked;
|
|
3016
|
+
resolvedFrom = range;
|
|
3017
|
+
const hit = fromCache(ctx, req, name, version, resolvedFrom);
|
|
3018
|
+
if (hit)
|
|
3019
|
+
return hit;
|
|
3020
|
+
}
|
|
3021
|
+
const v = versions[version];
|
|
3022
|
+
if (!isObj(v))
|
|
3023
|
+
throw new SkipError(`version ${version} is not on the registry`);
|
|
3024
|
+
vdoc = v;
|
|
3025
|
+
}
|
|
3026
|
+
const dist = isObj(vdoc.dist) ? vdoc.dist : {};
|
|
3027
|
+
const tarball = str(dist.tarball) ?? "";
|
|
3028
|
+
if (!allowedUrl(tarball, NPM_REGISTRY))
|
|
3029
|
+
throw new SkipError(`tarball host not allowed (${hostOf(tarball) || "no tarball"})`);
|
|
3030
|
+
const archive = await httpGet(ctx, tarball, NPM_REGISTRY, ctx.maxArchiveBytes, "archive");
|
|
3031
|
+
if (verifyNpmIntegrity(archive, str(dist.integrity), str(dist.shasum)) === false)
|
|
3032
|
+
throw new SkipError("integrity mismatch");
|
|
3033
|
+
const entries = parseTar(gunzipCapped(archive));
|
|
3034
|
+
return install(ctx, req, name, version, tarball, archive, entries, "first", resolvedFrom);
|
|
3035
|
+
}
|
|
3036
|
+
function pyFiles(list) {
|
|
3037
|
+
if (!Array.isArray(list))
|
|
3038
|
+
return [];
|
|
3039
|
+
const out = [];
|
|
3040
|
+
for (const f of list) {
|
|
3041
|
+
if (!isObj(f))
|
|
3042
|
+
continue;
|
|
3043
|
+
const filename = str(f.filename);
|
|
3044
|
+
const url = str(f.url);
|
|
3045
|
+
if (!filename || !url)
|
|
3046
|
+
continue;
|
|
3047
|
+
const file = { filename, url, yanked: f.yanked === true };
|
|
3048
|
+
const sha = isObj(f.digests) ? str(f.digests.sha256) : undefined;
|
|
3049
|
+
if (sha)
|
|
3050
|
+
file.sha256 = sha;
|
|
3051
|
+
if (typeof f.size === "number")
|
|
3052
|
+
file.size = f.size;
|
|
3053
|
+
out.push(file);
|
|
3054
|
+
}
|
|
3055
|
+
return out;
|
|
3056
|
+
}
|
|
3057
|
+
/** A pure-Python wheel beats an sdist (it is the code as installed); nothing else is usable. */
|
|
3058
|
+
function choosePyFile(files) {
|
|
3059
|
+
const live = files.filter((f) => !f.yanked);
|
|
3060
|
+
const pool = live.length ? live : files;
|
|
3061
|
+
const wheel = pool.find((f) => /-(?:py2\.)?py3-none-any\.whl$/i.test(f.filename));
|
|
3062
|
+
if (wheel)
|
|
3063
|
+
return wheel;
|
|
3064
|
+
return (pool.find((f) => /\.tar\.gz$/i.test(f.filename)) ??
|
|
3065
|
+
pool.find((f) => /\.zip$/i.test(f.filename)) ??
|
|
3066
|
+
null);
|
|
3067
|
+
}
|
|
3068
|
+
async function preparePypi(req, ctx) {
|
|
3069
|
+
const name = normPy(req.name);
|
|
3070
|
+
if (!/^[a-z0-9][a-z0-9.-]*$/.test(name))
|
|
3071
|
+
throw new SkipError("not a valid PyPI project name");
|
|
3072
|
+
rejectUnfetchable(req);
|
|
3073
|
+
let version = req.version;
|
|
3074
|
+
let resolvedFrom;
|
|
3075
|
+
let files;
|
|
3076
|
+
if (version !== null) {
|
|
3077
|
+
const hit = fromCache(ctx, req, name, version);
|
|
3078
|
+
if (hit)
|
|
3079
|
+
return hit;
|
|
3080
|
+
const doc = await getJson(ctx, `${PYPI_API}${encodeURIComponent(name)}/${encodeURIComponent(version)}/json`, PYPI_ORIGIN);
|
|
3081
|
+
files = pyFiles(doc.urls);
|
|
3082
|
+
}
|
|
3083
|
+
else {
|
|
3084
|
+
const range = req.range ?? "*";
|
|
3085
|
+
const doc = await getJson(ctx, `${PYPI_API}${encodeURIComponent(name)}/json`, PYPI_ORIGIN);
|
|
3086
|
+
const latest = isObj(doc.info) ? str(doc.info.version) : undefined;
|
|
3087
|
+
const releases = isObj(doc.releases) ? doc.releases : {};
|
|
3088
|
+
// A release whose every file is yanked is withdrawn: a range never picks
|
|
3089
|
+
// it (only an explicit pin may still fetch a yanked file) — the latest
|
|
3090
|
+
// release included.
|
|
3091
|
+
const latestFiles = pyFiles(doc.urls);
|
|
3092
|
+
if (latest && pyVersionMatches(latest, range) && latestFiles.some((f) => !f.yanked)) {
|
|
3093
|
+
version = latest;
|
|
3094
|
+
files = latestFiles;
|
|
3095
|
+
}
|
|
3096
|
+
else {
|
|
3097
|
+
const usable = Object.keys(releases).filter((k) => pyFiles(releases[k]).some((f) => !f.yanked));
|
|
3098
|
+
const picked = maxPyVersion(usable, range);
|
|
3099
|
+
if (!picked)
|
|
3100
|
+
throw new SkipError(`no release matches ${clip(range, 40)}`);
|
|
3101
|
+
version = picked;
|
|
3102
|
+
files = pyFiles(releases[picked]);
|
|
3103
|
+
}
|
|
3104
|
+
resolvedFrom = range;
|
|
3105
|
+
const hit = fromCache(ctx, req, name, version, resolvedFrom);
|
|
3106
|
+
if (hit)
|
|
3107
|
+
return hit;
|
|
3108
|
+
}
|
|
3109
|
+
const file = choosePyFile(files);
|
|
3110
|
+
if (!file)
|
|
3111
|
+
throw new SkipError("no pure-Python wheel or sdist");
|
|
3112
|
+
if (!allowedUrl(file.url, PYPI_FILES))
|
|
3113
|
+
throw new SkipError(`file host not allowed (${hostOf(file.url) || "no url"})`);
|
|
3114
|
+
if (file.size !== undefined && file.size > ctx.maxArchiveBytes) {
|
|
3115
|
+
throw new SkipError(`archive too large (${fmtBytes(file.size)} > ${fmtBytes(ctx.maxArchiveBytes)})`);
|
|
3116
|
+
}
|
|
3117
|
+
const archive = await httpGet(ctx, file.url, PYPI_FILES, ctx.maxArchiveBytes, "archive");
|
|
3118
|
+
if (file.sha256 && sha256hex(archive) !== file.sha256.toLowerCase())
|
|
3119
|
+
throw new SkipError("integrity mismatch");
|
|
3120
|
+
const lower = file.filename.toLowerCase();
|
|
3121
|
+
const isWheel = lower.endsWith(".whl");
|
|
3122
|
+
const entries = lower.endsWith(".zip") || isWheel ? parseZip(archive) : parseTar(gunzipCapped(archive));
|
|
3123
|
+
return install(ctx, req, name, version, file.url, archive, entries, isWheel ? "none" : "common-top", resolvedFrom);
|
|
3124
|
+
}
|
|
3125
|
+
/** A request's identity in a skip: its pin, else its unresolved range. */
|
|
3126
|
+
function requestIdentity(req) {
|
|
3127
|
+
return req.version !== null
|
|
3128
|
+
? { version: req.version }
|
|
3129
|
+
: req.range !== undefined
|
|
3130
|
+
? { range: req.range }
|
|
3131
|
+
: {};
|
|
3132
|
+
}
|
|
3133
|
+
async function prepareOne(req, ctx) {
|
|
3134
|
+
let out;
|
|
3135
|
+
try {
|
|
3136
|
+
out = req.ecosystem === "npm" ? await prepareNpm(req, ctx) : await preparePypi(req, ctx);
|
|
3137
|
+
}
|
|
3138
|
+
catch (e) {
|
|
3139
|
+
const reason = e instanceof SkipError ? e.message : `unpack failed: ${short(e)}`;
|
|
3140
|
+
out = {
|
|
3141
|
+
ok: false,
|
|
3142
|
+
skip: { ecosystem: req.ecosystem, name: req.name, ...requestIdentity(req), reason },
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
3145
|
+
// A returned failure (an empty cache hit) names the request like a thrown one.
|
|
3146
|
+
if (!out.ok)
|
|
3147
|
+
out.skip = { ...out.skip, ...requestIdentity(req) };
|
|
3148
|
+
if (out.ok)
|
|
3149
|
+
ctx.log(`depsources: ${req.ecosystem} ${out.source.name}@${out.source.version} ready (${out.source.files} files)`);
|
|
3150
|
+
else {
|
|
3151
|
+
// Which request: two workspaces can ask for one package at two versions.
|
|
3152
|
+
const which = req.version !== null ? `@${req.version}` : req.range ? ` ${req.range}` : "";
|
|
3153
|
+
ctx.log(`depsources: ${req.ecosystem} ${req.name}${which} skipped: ${out.skip.reason}`);
|
|
3154
|
+
}
|
|
3155
|
+
return out;
|
|
3156
|
+
}
|
|
3157
|
+
/**
|
|
3158
|
+
* Plan, fetch and unpack the third-party sources this PR's review needs.
|
|
3159
|
+
* NEVER throws, and its network phase never runs past `timeoutMs` (plus one
|
|
3160
|
+
* in-flight request settling) — the cache sweep and the planning before it
|
|
3161
|
+
* are local, synchronous and outside that budget. Every failure is a
|
|
3162
|
+
* `skipped` entry, and a locked package already cached is served without
|
|
3163
|
+
* touching the network at all (an unlocked range still asks the registry
|
|
3164
|
+
* which release it resolves to).
|
|
3165
|
+
*/
|
|
3166
|
+
export async function prepareDependencySources(tree, changedFiles, opts) {
|
|
3167
|
+
// A throwing logger must not be the one thing that breaks totality.
|
|
3168
|
+
const log = (line) => {
|
|
3169
|
+
try {
|
|
3170
|
+
opts.log?.(line);
|
|
3171
|
+
}
|
|
3172
|
+
catch {
|
|
3173
|
+
// ignore
|
|
3174
|
+
}
|
|
3175
|
+
};
|
|
3176
|
+
let root = opts.cacheDir;
|
|
3177
|
+
try {
|
|
3178
|
+
root = resolve(opts.cacheDir);
|
|
3179
|
+
}
|
|
3180
|
+
catch {
|
|
3181
|
+
// keep as given
|
|
3182
|
+
}
|
|
3183
|
+
const manifest = { dir: root, sources: [], skipped: [] };
|
|
3184
|
+
try {
|
|
3185
|
+
sweepStaleInstallDirs(root);
|
|
3186
|
+
pruneDependencyCache(root, opts.maxCacheBytes ?? DEFAULT_MAX_CACHE_BYTES, log, {
|
|
3187
|
+
inUseMs: opts.cacheInUseMs ?? DEFAULT_CACHE_IN_USE_MS,
|
|
3188
|
+
});
|
|
3189
|
+
}
|
|
3190
|
+
catch (e) {
|
|
3191
|
+
log(`depsources: cache prune failed: ${short(e)}`);
|
|
3192
|
+
}
|
|
3193
|
+
try {
|
|
3194
|
+
let requests;
|
|
3195
|
+
try {
|
|
3196
|
+
requests = planDependencyRequests(tree, changedFiles);
|
|
3197
|
+
}
|
|
3198
|
+
catch (e) {
|
|
3199
|
+
log(`depsources: planning failed: ${short(e)}`);
|
|
3200
|
+
return manifest;
|
|
3201
|
+
}
|
|
3202
|
+
const cap = Math.max(0, opts.maxPackages ?? DEFAULT_MAX_PACKAGES);
|
|
3203
|
+
const take = requests.slice(0, cap);
|
|
3204
|
+
// `globalThis.fetch` is typed as always present; a runtime without it
|
|
3205
|
+
// still gets a skip per package rather than a throw.
|
|
3206
|
+
const fetchImpl = opts.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch : undefined);
|
|
3207
|
+
const results = new Array(take.length);
|
|
3208
|
+
if (!fetchImpl) {
|
|
3209
|
+
take.forEach((r, i) => {
|
|
3210
|
+
results[i] = {
|
|
3211
|
+
ok: false,
|
|
3212
|
+
skip: {
|
|
3213
|
+
ecosystem: r.ecosystem,
|
|
3214
|
+
name: r.name,
|
|
3215
|
+
...requestIdentity(r),
|
|
3216
|
+
reason: "no fetch implementation",
|
|
3217
|
+
},
|
|
3218
|
+
};
|
|
3219
|
+
});
|
|
3220
|
+
}
|
|
3221
|
+
else {
|
|
3222
|
+
const ctx = {
|
|
3223
|
+
root,
|
|
3224
|
+
fetch: fetchImpl,
|
|
3225
|
+
deadline: Date.now() + (opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
3226
|
+
maxArchiveBytes: opts.maxArchiveBytes ?? DEFAULT_MAX_ARCHIVE_BYTES,
|
|
3227
|
+
maxUnpackedBytes: opts.maxUnpackedBytes ?? DEFAULT_MAX_UNPACKED_BYTES,
|
|
3228
|
+
log,
|
|
3229
|
+
};
|
|
3230
|
+
let next = 0;
|
|
3231
|
+
const worker = async () => {
|
|
3232
|
+
for (;;) {
|
|
3233
|
+
const i = next++;
|
|
3234
|
+
const req = take[i];
|
|
3235
|
+
if (!req)
|
|
3236
|
+
return;
|
|
3237
|
+
results[i] = await prepareOne(req, ctx);
|
|
3238
|
+
}
|
|
3239
|
+
};
|
|
3240
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, take.length) }, worker));
|
|
3241
|
+
}
|
|
3242
|
+
for (const r of results) {
|
|
3243
|
+
if (!r)
|
|
3244
|
+
continue;
|
|
3245
|
+
if (r.ok)
|
|
3246
|
+
manifest.sources.push(r.source);
|
|
3247
|
+
else
|
|
3248
|
+
manifest.skipped.push(r.skip);
|
|
3249
|
+
}
|
|
3250
|
+
for (const r of requests.slice(cap))
|
|
3251
|
+
manifest.skipped.push({
|
|
3252
|
+
ecosystem: r.ecosystem,
|
|
3253
|
+
name: r.name,
|
|
3254
|
+
...requestIdentity(r),
|
|
3255
|
+
reason: "over the per-PR package cap",
|
|
3256
|
+
});
|
|
3257
|
+
log(`depsources: ${manifest.sources.length} source(s), ${manifest.skipped.length} skipped`);
|
|
3258
|
+
}
|
|
3259
|
+
catch (e) {
|
|
3260
|
+
log(`depsources: failed: ${short(e)}`);
|
|
3261
|
+
}
|
|
3262
|
+
return manifest;
|
|
3263
|
+
}
|
|
3264
|
+
// ---------------------------------------------------------------------------
|
|
3265
|
+
// Cache size cap
|
|
3266
|
+
// ---------------------------------------------------------------------------
|
|
3267
|
+
/**
|
|
3268
|
+
* Keep the cache under `maxBytes`: complete entries are sized from their
|
|
3269
|
+
* marker (bytes plus a per-file charge) and aged by its mtime (a hit touches it), and the oldest are removed
|
|
3270
|
+
* until the rest fit. Incomplete dirs are the stale-tmp sweep's business, not
|
|
3271
|
+
* this one's. Returns the number of entries removed. Never throws on one bad
|
|
3272
|
+
* entry.
|
|
3273
|
+
*/
|
|
3274
|
+
export function pruneDependencyCache(root, maxBytes, log = () => { }, opts = {}) {
|
|
3275
|
+
const inUseMs = opts.inUseMs ?? 0;
|
|
3276
|
+
const inUseSince = Date.now() - inUseMs;
|
|
3277
|
+
const entries = [];
|
|
3278
|
+
let ecos = [];
|
|
3279
|
+
try {
|
|
3280
|
+
ecos = readdirSync(root);
|
|
3281
|
+
}
|
|
3282
|
+
catch {
|
|
3283
|
+
return 0;
|
|
3284
|
+
}
|
|
3285
|
+
for (const eco of ecos) {
|
|
3286
|
+
let names = [];
|
|
3287
|
+
try {
|
|
3288
|
+
names = readdirSync(join(root, eco));
|
|
3289
|
+
}
|
|
3290
|
+
catch {
|
|
3291
|
+
continue;
|
|
3292
|
+
}
|
|
3293
|
+
for (const n of names) {
|
|
3294
|
+
const dir = join(root, eco, n);
|
|
3295
|
+
const marker = readMarker(dir);
|
|
3296
|
+
if (!marker)
|
|
3297
|
+
continue;
|
|
3298
|
+
try {
|
|
3299
|
+
entries.push({
|
|
3300
|
+
dir,
|
|
3301
|
+
bytes: marker.bytes + (marker.files + marker.dirs) * CACHE_FILE_OVERHEAD_BYTES,
|
|
3302
|
+
mtimeMs: statSync(join(dir, MARKER)).mtimeMs,
|
|
3303
|
+
});
|
|
3304
|
+
}
|
|
3305
|
+
catch {
|
|
3306
|
+
// vanished between the listing and the stat
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
let total = entries.reduce((n, e) => n + e.bytes, 0);
|
|
3311
|
+
if (total <= maxBytes)
|
|
3312
|
+
return 0;
|
|
3313
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
3314
|
+
let removed = 0;
|
|
3315
|
+
for (const e of entries) {
|
|
3316
|
+
if (total <= maxBytes)
|
|
3317
|
+
break;
|
|
3318
|
+
// Oldest first, so every entry after this one is in use too.
|
|
3319
|
+
if (inUseMs > 0 && e.mtimeMs > inUseSince)
|
|
3320
|
+
break;
|
|
3321
|
+
try {
|
|
3322
|
+
rmSync(e.dir, { recursive: true, force: true });
|
|
3323
|
+
total -= e.bytes;
|
|
3324
|
+
removed++;
|
|
3325
|
+
}
|
|
3326
|
+
catch {
|
|
3327
|
+
// try the next one
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
log(`depsources: cache over ${Math.round(maxBytes / MiB)} MiB — evicted ${removed} entr${removed === 1 ? "y" : "ies"}`);
|
|
3331
|
+
return removed;
|
|
3332
|
+
}
|
|
3333
|
+
// ---------------------------------------------------------------------------
|
|
3334
|
+
// Rendering
|
|
3335
|
+
// ---------------------------------------------------------------------------
|
|
3336
|
+
const MAX_RENDERED_SKIPS = 8;
|
|
3337
|
+
/** The round prompt's block. "" when there is nothing to say at all. Every
|
|
3338
|
+
* field goes through `flatControls`: `why` carries the PR's own file paths,
|
|
3339
|
+
* and git allows a newline in one — raw, it would open a line of
|
|
3340
|
+
* daemon-authored prompt. */
|
|
3341
|
+
export function renderDependencyNote(m) {
|
|
3342
|
+
const f = flatControls;
|
|
3343
|
+
if (!m.sources.length && !m.skipped.length)
|
|
3344
|
+
return "";
|
|
3345
|
+
const lines = [];
|
|
3346
|
+
if (m.sources.length) {
|
|
3347
|
+
lines.push("**Third-party sources (read-only, at the PR's locked versions).** The worktree has no installed dependencies, so these were unpacked for reading. When a finding depends on what a library does — the names it returns, the encodings it supports, its defaults, its error cases — check it in the library's code here and cite `path:line` from it. These are not part of the PR: never review the library itself. Include this whole block in EVERY pass's task string.");
|
|
3348
|
+
for (const s of m.sources) {
|
|
3349
|
+
const unlocked = s.range !== undefined
|
|
3350
|
+
? ` (no lockfile pin: the registry's resolved match for \`${f(s.range)}\`)`
|
|
3351
|
+
: "";
|
|
3352
|
+
lines.push(`- ${s.ecosystem} \`${f(s.name)}@${f(s.version)}\`${unlocked} — ${f(s.why)} → \`${f(s.path)}\``);
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
else {
|
|
3356
|
+
lines.push("**Third-party sources: none could be unpacked for this PR.** The worktree has no installed dependencies. A finding that depends on what a library does still stands on well-known, documented behaviour: name the library, the version the PR pins and the behaviour you rely on, so the validator can weigh it. Include this whole block in EVERY pass's task string.");
|
|
3357
|
+
}
|
|
3358
|
+
if (m.skipped.length) {
|
|
3359
|
+
const shown = m.skipped
|
|
3360
|
+
.slice(0, MAX_RENDERED_SKIPS)
|
|
3361
|
+
.map((s) => `\`${f(s.name)}${s.version ? `@${f(s.version)}` : s.range ? ` ${f(s.range)}` : ""}\` (${f(s.reason)})`);
|
|
3362
|
+
const more = m.skipped.length - shown.length;
|
|
3363
|
+
lines.push(`Not available: ${shown.join("; ")}${more > 0 ? `; +${more} more` : ""}.`);
|
|
3364
|
+
}
|
|
3365
|
+
return lines.join("\n");
|
|
3366
|
+
}
|
|
3367
|
+
//# sourceMappingURL=depsources.js.map
|