From e12ed1c98dd5434d7b15103df7adc80dd5f25225 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Tue, 25 Aug 2026 04:22:07 +0900 Subject: [PATCH 1/3] feat(cli): support '!' exclusion patterns in bundle.packages.embed [RED-892] An entry prefixed with '!' removes the packages it matches from what the entries before it selected, so entries apply in order: ['@acme/*', '!@acme/legacy'] embeds the whole scope except @acme/legacy, while the reverse order embeds the whole scope. Exclusions are applied to an entry's matches before they are resolved, rather than by pruning the finished plan, so the per-entry diagnostics stay in step with what actually ships: an entry never fails over, nor warns about, a package the configuration goes on to exclude. Matching happens at the version-filtered level so that appending an entry's own pin as an exclusion cancels it, and version-less lockfile records (git resolutions, workspace links) can neither silence nor explain a pinned entry, since they match any pin. A configuration whose entries select no packages at all is now reported as a warning, which catches reading '!' as gitignore's implicit "everything except" rather than as a subtraction. Configurations without a '!' entry are unaffected: with no exclusions the filtered and unfiltered match sets are identical, so neither new branch can be reached. Co-Authored-By: Claude Opus 5 --- .../__tests__/checkly-config-loader.spec.ts | 2 +- .../configs/embedded-packages-valid.ts | 2 +- .../__tests__/materializer.spec.ts | 147 ++++++++++++++++++ .../embedded-packages/__tests__/spec.spec.ts | 47 ++++++ .../embedded-packages/materializer.ts | 98 ++++++++++-- .../src/services/embedded-packages/spec.ts | 46 ++++-- 6 files changed, 317 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts b/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts index c8d11edf..f6fc12fb 100644 --- a/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts +++ b/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts @@ -111,7 +111,7 @@ describe('loadChecklyConfig()', () => { ['embedded-packages-valid.ts'], ) expect(config.bundle?.packages?.embed) - .toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*']) + .toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*', '!@acme/foo']) }) it('rejects a bundle.packages.embed that is not an array', async () => { await expect(loadChecklyConfig( diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts index f54da367..7a67d0bb 100644 --- a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts @@ -5,7 +5,7 @@ const config = defineConfig({ logicalId: 'test-config-project', bundle: { packages: { - embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*'], + embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*', '!@acme/foo'], }, }, }) diff --git a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts index 252ecb47..821d774c 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts @@ -45,6 +45,10 @@ async function captureStderr (fn: () => Promise): Promise { return written } +// The lead-in of the warning a configuration gets when it selects no +// packages at all. +const NOTHING_MATCHED = `No packages matched 'bundle.packages.embed'` + describe('EmbeddedPackagesMaterializer', () => { let workspaceRoot: string let homedir: string @@ -355,6 +359,149 @@ packages: expect(issues[0].message).not.toContain('workspace') }) + it('drops what a later ! entry excludes', async () => { + const { tarballs, issues, warnings } = await makeMaterializer(['@acme/*', 'bar', '!bar']).plan() + expect(issues).toEqual([]) + expect(warnings).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['@acme+foo@1.2.3.tgz']) + }) + + it('applies entries in order, so an exclusion before an inclusion removes nothing', async () => { + const { tarballs, issues } = await makeMaterializer(['!bar', 'bar']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz', 'bar@3.0.0.tgz']) + }) + + it('excludes only the pinned version when the ! entry carries one', async () => { + const { tarballs, issues } = await makeMaterializer(['bar', '!bar@2.0.0']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@3.0.0.tgz']) + }) + + it('treats an exclusion that removes nothing as a no-op, not an error', async () => { + const { tarballs, issues } = await makeMaterializer(['bar', '!@nomatch/*']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz', 'bar@3.0.0.tgz']) + }) + + it('reports nothing for an entry whose every match a later ! entry removed', async () => { + // The entry resolved fine; the configuration then asked for its + // matches back out. That is not the unresolvable-spec error an entry + // matching nothing in the first place would get. + const { tarballs, issues, warnings } = await makeMaterializer(['bar', '!bar']).plan() + expect(issues).toEqual([]) + expect(tarballs).toEqual([]) + expect(warnings).toEqual([expect.stringContaining(NOTHING_MATCHED)]) + }) + + it('silently cancels a pinned entry that a later ! entry pins away', async () => { + // Appending '!name@version' is the natural way to switch one embed + // off. Matching at name level would leave the entry alive on bar's + // other versions and fail with a version-not-found error naming 3.0.0 + // as all the lockfile has, while 2.0.0 is right there. + const { tarballs, issues, warnings } = await makeMaterializer(['bar@2.0.0', '!bar@2.0.0']).plan() + expect(issues).toEqual([]) + expect(tarballs).toEqual([]) + expect(warnings).toEqual([expect.stringContaining(NOTHING_MATCHED)]) + }) + + it('still reports a pin that matches nothing even when a ! entry removes the other versions', async () => { + // The exclusions are not what left this entry empty, so the typo in + // the pin must not be swallowed along with them. + const { issues } = await makeMaterializer(['bar@9.9.9', '!bar']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].spec).toBe('bar@9.9.9') + // The pin is what is wrong, not the install: the message must still + // name the versions the lockfile does have. + expect(issues[0].type).toBe('spec-version-not-found') + expect(issues[0].message).toContain('lockfile has: 2.0.0, 3.0.0') + }) + + it('still reports a mistyped pin when a ! entry only removes version-less matches of that name', async () => { + // A git resolution is recorded with no version, so it matches any pin. + // If it could satisfy the emptied-entry guard, the '!bar' entry would + // silence 'bar@9.9.9' — a stale pin would then drop out of the bundle + // with no error at all, and the install would fail on the runner. + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + bar@2.0.0: + resolution: {integrity: ${barIntegrity}} + 'bar@https://codeload.github.com/user/bar/tar.gz/abc123': + resolution: {tarball: https://codeload.github.com/user/bar/tar.gz/abc123} + keep@1.0.0: + resolution: {integrity: ${barIntegrity}} +`) + const { tarballs, issues } = await makeMaterializer(['keep', 'bar@9.9.9', '!bar']).plan() + expect(tarballs.map(t => t.archiveFilename)).toEqual(['keep@1.0.0.tgz']) + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-version-not-found') + expect(issues[0].spec).toBe('bar@9.9.9') + }) + + it('keeps the accurate not-embeddable reason for a pinned entry a ! entry also matches', async () => { + // A git resolution has no version, so it cannot satisfy the pin and the + // entry has to fail either way. It must fail saying the package cannot + // be embedded, not that the lockfile has never heard of it. + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + 'git-dep@https://codeload.github.com/user/git-dep/tar.gz/abc123': + resolution: {tarball: https://codeload.github.com/user/git-dep/tar.gz/abc123} + bar@2.0.0: + resolution: {integrity: ${barIntegrity}} +`) + const { issues } = await makeMaterializer(['git-dep@1.0.0', '!git-dep']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-embeddable') + expect(issues[0].message).toContain('git, file or URL dependency') + }) + + it('does not turn surviving unfetchable matches into an error when the registry matches were excluded', async () => { + // A bare * reaches bar (registry) and git-dep (unfetchable). Excluding + // bar leaves only git-dep, which must not promote the entry into a + // fatal 'cannot be embedded' — that reason applies to an entry with + // nothing else to embed, not to one deliberately emptied. + const { tarballs, issues, warnings } = await makeMaterializer(['*', '!bar']).plan() + expect(issues).toEqual([]) + expect(tarballs).toEqual([]) + // Only the nothing-embedded notice; no 'cannot be embedded' error. + expect(warnings).toEqual([expect.stringContaining(NOTHING_MATCHED)]) + }) + + it('silences the unfetchable warning for a package a later ! entry excludes', async () => { + const { tarballs, issues, warnings } = await makeMaterializer(['*', '!git-dep']).plan() + expect(issues).toEqual([]) + expect(warnings).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz', 'bar@3.0.0.tgz']) + }) + + it('lets a ! entry resolve a wildcard whose only match cannot be embedded', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + '@acme/legacy@https://codeload.github.com/user/legacy/tar.gz/abc123': + resolution: {tarball: https://codeload.github.com/user/legacy/tar.gz/abc123} + bar@2.0.0: + resolution: {integrity: ${barIntegrity}} +`) + // Without the exclusion this is a fatal spec-not-embeddable, since + // the scope's only entry is a git dependency. + const { tarballs, issues, warnings } = await makeMaterializer(['@acme/*', '!@acme/legacy']).plan() + expect(issues).toEqual([]) + expect(tarballs).toEqual([]) + expect(warnings).toEqual([expect.stringContaining(NOTHING_MATCHED)]) + }) + + it('warns when the configuration selects nothing at all', async () => { + // `!` subtracts from what came before, so a list of nothing but + // exclusions is not gitignore's "everything except" — it is empty. + const { tarballs, issues, warnings } = await makeMaterializer(['!@acme/foo']).plan() + expect(issues).toEqual([]) + expect(tarballs).toEqual([]) + expect(warnings).toEqual([expect.stringContaining(NOTHING_MATCHED)]) + }) + it('converts scope slashes for the archive filename', async () => { const { tarballs } = await makeMaterializer(['@acme/foo']).plan() expect(tarballs.map(t => t.archiveFilename)).toEqual(['@acme+foo@1.2.3.tgz']) diff --git a/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts index fa9c5a8e..824e6db0 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts @@ -8,6 +8,7 @@ describe('parseEmbeddedPackageSpec()', () => { raw: 'some-package', name: 'some-package', version: undefined, + exclude: false, }) }) @@ -16,6 +17,7 @@ describe('parseEmbeddedPackageSpec()', () => { raw: '@acme/private-utils', name: '@acme/private-utils', version: undefined, + exclude: false, }) }) @@ -24,6 +26,7 @@ describe('parseEmbeddedPackageSpec()', () => { raw: 'some-package@2.1.0', name: 'some-package', version: '2.1.0', + exclude: false, }) }) @@ -32,6 +35,7 @@ describe('parseEmbeddedPackageSpec()', () => { raw: '@acme/private-utils@1.0.0-beta.3', name: '@acme/private-utils', version: '1.0.0-beta.3', + exclude: false, }) }) @@ -45,6 +49,7 @@ describe('parseEmbeddedPackageSpec()', () => { raw: '@acme/AuthClient@1.0.0', name: '@acme/AuthClient', version: '1.0.0', + exclude: false, }) }) @@ -56,6 +61,48 @@ describe('parseEmbeddedPackageSpec()', () => { expect(parseEmbeddedPackageSpec('some-package@ 2.1.0 ').version).toBe('2.1.0') }) + it('parses a leading ! as an exclusion, keeping it in the raw entry', () => { + expect(parseEmbeddedPackageSpec('!some-package')).toEqual({ + raw: '!some-package', + name: 'some-package', + version: undefined, + exclude: true, + }) + }) + + it('parses an excluded scoped name without mistaking the scope for a version', () => { + expect(parseEmbeddedPackageSpec('!@acme/private-utils')).toEqual({ + raw: '!@acme/private-utils', + name: '@acme/private-utils', + version: undefined, + exclude: true, + }) + }) + + it('parses an excluded name@version pin', () => { + expect(parseEmbeddedPackageSpec('!some-package@2.1.0')).toEqual({ + raw: '!some-package@2.1.0', + name: 'some-package', + version: '2.1.0', + exclude: true, + }) + }) + + it('parses an excluded wildcard', () => { + const spec = parseEmbeddedPackageSpec('!@acme/*') + expect(spec.exclude).toBe(true) + expect(spec.name).toBe('@acme/*') + expect(specMatchesPackageName(spec, '@acme/private-utils')).toBe(true) + }) + + it('rejects a bare !', () => { + expect(() => parseEmbeddedPackageSpec('!')).toThrow(/must name a package or pattern after '!'/) + }) + + it('rejects an invalid name behind a !', () => { + expect(() => parseEmbeddedPackageSpec('!Not A Valid Name')).toThrow(/not a valid npm package name/) + }) + it('rejects an empty string', () => { expect(() => parseEmbeddedPackageSpec('')).toThrow(InvalidEmbeddedPackageSpecError) }) diff --git a/packages/cli/src/services/embedded-packages/materializer.ts b/packages/cli/src/services/embedded-packages/materializer.ts index 82efc823..43a5172a 100644 --- a/packages/cli/src/services/embedded-packages/materializer.ts +++ b/packages/cli/src/services/embedded-packages/materializer.ts @@ -296,14 +296,78 @@ export class EmbeddedPackagesMaterializer { entry.version === undefined || !registryKeys.has(`${entry.name}@${entry.version}`)) const tarballs = new Map() - for (const spec of specs) { + for (const [index, spec] of specs.entries()) { + // Exclusions select nothing themselves; they subtract from the entries + // before them, via the kept() filter below. One that removes nothing is + // a valid no-op rather than an error, unlike an unresolvable inclusion. + if (spec.exclude) { + // Matching nothing is a valid outcome, so a misspelled `!` entry + // cannot be an error — but it silently fails to keep a package out, + // which is worth a line on the debug channel. + if (!packages.registry.some(entry => specMatchesPackageName(spec, entry.name)) + && !relevantExcluded.some(entry => specMatchesPackageName(spec, entry.name))) { + debug('exclusion %s matches no package in the lockfile', spec.raw) + } + continue + } + + // Entries apply in order, so only the `!` entries that come after this + // one take anything away from it. Filtering the matches up front, + // rather than pruning the finished plan, keeps the diagnostics below in + // step with what actually ships: an entry never warns about, or fails + // over, a package the configuration goes on to exclude. + const laterExclusions = specs.slice(index + 1).filter(other => other.exclude) + const kept = (entries: T[]) => entries.filter(entry => + !laterExclusions.some(other => specMatchesPackageName(other, entry.name) + && (other.version === undefined || other.version === entry.version))) + + // nameMatches stays unfiltered: it only feeds the diagnostics below, + // which describe the lockfile as it is — a mistyped pin should still be + // told which versions exist, even when an unrelated exclusion removed + // them from what this entry embeds. const nameMatches = packages.registry.filter(entry => specMatchesPackageName(spec, entry.name)) - const candidates = nameMatches + const allCandidates = nameMatches .filter(entry => spec.version === undefined || entry.version === spec.version) - - const nameExcluded = relevantExcluded.filter(entry => specMatchesPackageName(spec, entry.name)) - const looseExcluded = nameExcluded.filter(entry => - spec.version === undefined || entry.version === undefined || entry.version === spec.version) + const allLooseExcluded = relevantExcluded.filter(entry => specMatchesPackageName(spec, entry.name) + && (spec.version === undefined || entry.version === undefined || entry.version === spec.version)) + // The strict set drops version-less entries (workspace links, git + // resolutions), which match any pin and so can neither explain nor + // silence a pinned entry that simply named a version nothing has. + const allStrictExcluded = allLooseExcluded.filter(entry => + spec.version === undefined || entry.version === spec.version) + + const candidates = kept(allCandidates) + const looseExcluded = kept(allLooseExcluded) + const strictExcluded = kept(allStrictExcluded) + + // Everything this entry could have embedded was removed by a later `!` + // entry, which is the configured outcome: it embeds nothing and reports + // nothing instead of looking unresolvable. Two ways to get there, and + // both require the exclusions to be the whole reason: an entry that had + // embeddable matches is silent once every one of them is excluded, and + // an entry that only ever reached un-embeddable matches is silent only + // once every one of *those* is excluded — one that survives still + // carries the not-embeddable error it would raise on its own. + // + // Comparing at the version-filtered level matters: an entry disabled by + // appending its own pin as an exclusion ('bar@2.0.0', '!bar@2.0.0') + // would otherwise stay alive on the package's other versions and fail + // with a version-not-found error that names them as the only ones in + // the lockfile. + // + // An entry emptied this way also drops the skip warning for any + // un-embeddable package it reached but did not exclude. That is a + // deliberate trade: keeping the warning means keeping the entry alive + // past this point, where an un-embeddable match with nothing left to + // embed alongside it is a fatal error. The debug line below is what + // explains an entry that embedded nothing. + if (candidates.length === 0 + && (allCandidates.length > 0 + || (allStrictExcluded.length > 0 && strictExcluded.length === 0))) { + debug('spec %s: embeds nothing, later exclusions removed every embeddable match (reached: %j)', + spec.raw, [...allCandidates, ...allStrictExcluded].map(entry => `${entry.name}@${entry.version}`)) + continue + } if (candidates.length === 0) { // Excluded entries matching the exact pin (or any entry, when @@ -312,11 +376,14 @@ export class EmbeddedPackagesMaterializer { // Version-less excluded entries (e.g. workspace links) are a last // resort, so a pinned spec is never blamed on one while a better // explanation exists. - const strictExcluded = nameExcluded.filter(entry => - spec.version === undefined || entry.version === spec.version) + // The fallback reads the unfiltered set, for the same reason + // nameMatches is unfiltered: an entry that still has to fail should + // fail with the most accurate reason the lockfile offers, and a + // version-less excluded entry (a git resolution, a workspace link) + // is often the only thing that explains it. const excludedMatches = strictExcluded.length > 0 ? strictExcluded - : nameMatches.length === 0 ? looseExcluded : [] + : nameMatches.length === 0 ? allLooseExcluded : [] if (excludedMatches.length > 0) { const reasons = capList([...new Set(excludedMatches.map(entry => entry.reason))], '; ', '; and ') issues.push({ @@ -385,6 +452,19 @@ export class EmbeddedPackagesMaterializer { } } + // Without exclusions every entry either embeds something or raises an + // issue, so an empty plan with nothing to report can only come from `!` + // entries — most likely a config that reads them as gitignore's implicit + // "everything except" rather than as a subtraction from what came + // before. Embedding nothing is not an error, but saying so beats letting + // the user find out from an install failure on the runner. + if (specs.length > 0 && tarballs.size === 0 && issues.length === 0) { + warnings.push( + `No packages matched 'bundle.packages.embed', so nothing will be embedded into the code bundle.` + + ` An exclusion entry ('!...') only removes packages that the entries before it selected.`, + ) + } + debug('plan: %d tarballs, %d issues, %d warnings', tarballs.size, issues.length, warnings.length) return { diff --git a/packages/cli/src/services/embedded-packages/spec.ts b/packages/cli/src/services/embedded-packages/spec.ts index 1815a5fa..d955a06c 100644 --- a/packages/cli/src/services/embedded-packages/spec.ts +++ b/packages/cli/src/services/embedded-packages/spec.ts @@ -3,10 +3,11 @@ import semver from 'semver' /** * A parsed `bundle.packages.embed` entry: a package name — or a name * pattern with `*` wildcards — with an optional exact version pin - * (`name` or `name@version`). + * (`name` or `name@version`), optionally prefixed with `!` to make it an + * exclusion. */ export interface EmbeddedPackageSpec { - /** The raw config entry, kept for error messages. */ + /** The raw config entry, `!` prefix included, kept for error messages. */ raw: string /** * The package name, e.g. `@acme/private-utils` — or, when @@ -22,6 +23,12 @@ export interface EmbeddedPackageSpec { * that scope; a bare `*` matches only unscoped names). */ namePattern?: RegExp + /** + * True when the entry was prefixed with `!`: instead of selecting + * packages, it removes the ones it matches from what the entries before + * it selected. + */ + exclude: boolean } /** @@ -64,28 +71,39 @@ export class InvalidEmbeddedPackageSpecError extends Error { } /** - * Parses a `bundle.packages.embed` entry into a package name and an - * optional exact version pin. + * Parses a `bundle.packages.embed` entry into a package name, an optional + * exact version pin and whether the entry excludes rather than selects. * * Accepts `name` (embed every lockfile version of the package) and * `name@version` with an exact semver version. The name may contain `*` * wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`), each matching any run - * of characters except `/`. Version ranges are rejected: the embedded - * tarball must be the exact artifact the lockfile resolved, so a range has - * nothing meaningful to select against. A leading `v` is stripped, but the - * version is otherwise kept as written (including any build metadata) so - * it compares exactly against lockfile versions. + * of characters except `/`. A `!` prefix (`!@acme/legacy`, `!@acme/*`, + * `!legacy@2.1.0`) marks the entry as an exclusion, subtracting from what + * the entries before it selected. Version ranges are rejected: the + * embedded tarball must be the exact artifact the lockfile resolved, so a + * range has nothing meaningful to select against. A leading `v` is + * stripped, but the version is otherwise kept as written (including any + * build metadata) so it compares exactly against lockfile versions. */ export function parseEmbeddedPackageSpec (raw: string): EmbeddedPackageSpec { if (typeof raw !== 'string' || raw === '') { throw new InvalidEmbeddedPackageSpecError(String(raw), `must be a non-empty string`) } + // The `!` has to come off before anything else is read: on the raw + // `!@acme/foo` the version separator below would land on the scope marker + // at index 1 and parse the entry as name `!` at version `acme/foo`. + const exclude = raw.startsWith('!') + const pattern = exclude ? raw.slice(1) : raw + if (pattern === '') { + throw new InvalidEmbeddedPackageSpecError(raw, `must name a package or pattern after '!'`) + } + // A version separator is any `@` past the first character, which keeps the // scope marker of `@scope/name` intact. - const versionSeparator = raw.lastIndexOf('@') - const name = versionSeparator > 0 ? raw.slice(0, versionSeparator) : raw - const rawVersion = versionSeparator > 0 ? raw.slice(versionSeparator + 1) : undefined + const versionSeparator = pattern.lastIndexOf('@') + const name = versionSeparator > 0 ? pattern.slice(0, versionSeparator) : pattern + const rawVersion = versionSeparator > 0 ? pattern.slice(versionSeparator + 1) : undefined // A wildcard name must still be name-shaped once every `*` stands in for // name characters. (`*` itself appears in npm's legacy name charset, but @@ -100,7 +118,7 @@ export function parseEmbeddedPackageSpec (raw: string): EmbeddedPackageSpec { const namePattern = wildcard ? compileNamePattern(name) : undefined if (rawVersion === undefined) { - return { raw, name, namePattern } + return { raw, name, namePattern, exclude } } // Trim before validating: semver.valid() tolerates surrounding whitespace, @@ -115,5 +133,5 @@ export function parseEmbeddedPackageSpec (raw: string): EmbeddedPackageSpec { ) } - return { raw, name, version, namePattern } + return { raw, name, version, namePattern, exclude } } From ce210379fe13cb6d04fdc74356c0b185c9dd3817 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Tue, 25 Aug 2026 04:22:20 +0900 Subject: [PATCH 2/3] docs(cli): document embed exclusion patterns [RED-892] Covers the '!' prefix and the in-order semantics in the config JSDoc and the AI-context Playwright reference: what an exclusion subtracts from, that one removing nothing is a no-op, that silencing an entry also silences skip warnings for packages it matched but did not exclude (with a pointer to the debug channel), and that a configuration selecting no packages is reported as a warning. Co-Authored-By: Claude Opus 5 --- .../references/configure-playwright-checks.md | 2 +- packages/cli/src/services/checkly-config-loader.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ai-context/references/configure-playwright-checks.md b/packages/cli/src/ai-context/references/configure-playwright-checks.md index 98412782..e2d16599 100644 --- a/packages/cli/src/ai-context/references/configure-playwright-checks.md +++ b/packages/cli/src/ai-context/references/configure-playwright-checks.md @@ -16,7 +16,7 @@ - In a workspace (monorepo) whose code bundle covers only part of the workspace, the bundled lockfile is pruned automatically: the CLI regenerates it (via `pnpm install --lockfile-only` / `npm install --package-lock-only` / `bun install --lockfile-only` / `yarn install --mode=update-lockfile` in a temp dir) so it only references the packages actually in the bundle — otherwise the remote install would try to fetch dependencies of workspace members that were omitted or shipped as dependency-free placeholder manifests, which fails outright for private packages. Supported for `pnpm-lock.yaml` versions 6/9, `package-lock.json` versions 2/3, the text `bun.lock` version 1 and Yarn Berry `yarn.lock` files (for bun projects, keep registry configuration in `.npmrc`, which bun reads: `bunfig.toml` is not carried into the regeneration — recorded resolutions keep their URLs, but whenever bun declines to reuse the lockfile — it is out of date with a manifest, or a workspace member's name collides with a registry dependency — bun re-resolves those entries against the wrong registry, disclosing the package names to it (typically the public registry), and pruning rejects the result with a warning; for yarn projects, `.yarnrc.yml` is likewise not carried into the regeneration, which is safe because Berry lockfiles are registry-agnostic and the regeneration reuses recorded resolutions without the network — settings like `approvedGitRepositories` and `npmScopes` only affect new resolutions, which pruning never performs — and the regeneration runs with yarn's network access disabled outright, since it never needs it: a lockfile that is out of date with a manifest then fails fast with a warning instead of resolving the missing package against the wrong registry and disclosing its name; yarn's hardened mode is disabled for the same reason; `yarn patch` files under `.yarn/patches` are bundled automatically because the regeneration reads them; a `yarn` binary that resolves to Yarn Classic on a Berry project is refused before it can run, because Classic would silently perform a full install); when a bundled lockfile over-describes a partial-workspace bundle but pruning cannot run — other lockfile formats, Yarn Classic v1 lockfiles, a `yarn` binary that resolves to Yarn Classic on a Berry project (set the `packageManager` field so Corepack provisions Yarn 2+), bun's binary `bun.lockb` (regenerate a text lockfile with `bun install --save-text-lockfile`), the package manager binary not being installed on the machine running the CLI, `excludeLinksFromLockfile`, a recorded pnpmfile checksum without a bundled pnpmfile, a workspace member whose version cannot be determined, among others — the original lockfile ships unchanged and the CLI prints a note saying so. Other skips are silent: nothing to prune (the bundle contains the full workspace, or regeneration produced identical bytes), no bundled lockfile to prune, or pruning disabled via `CHECKLY_LOCKFILE_PRUNE=0`; silent skip reasons are visible via `DEBUG='checkly:cli:services:check-parser:*'`. When pruning runs but cannot produce a provably pruned copy of the original — the lockfile is out of date with a `package.json`, the package manager could not run or timed out, the lockfile could not be read or written, or verification failed, among others — the original ships unchanged with a warning. Set `CHECKLY_LOCKFILE_PRUNE=0` to disable pruning. - Checkly caches installed dependencies between runs, keyed off the workspace's lock file, every workspace member's `package.json` and `.npmrc` (whether or not the member is in the bundle), bundled pnpmfile contents, and the resolved `bundle.packages.embed` tarball set (filtered to what the pruned lockfile still references when pruning applied) — plus, as additional inputs, any synthesized placeholder manifests shipped in the bundle and the pruned lockfile when pruning applied. Because the bundle-specific inputs follow the bundle, the key can change without a file edit — e.g. when a different set of workspace members ends up in the bundle. To force a reinstall declaratively, set `caching.dependencyCache.version` (a string or a safe integer) at the top level of `checkly.config.ts` (not per check — one code bundle serves all Playwright Check Suites) and change its value whenever the cache should be invalidated; scheduled checks pick up the change on the next `checkly deploy`. Unset or empty-string values leave the cache key unchanged, so a dynamic value such as `version: process.env.DEPENDENCY_CACHE_VERSION` is safe when the variable is not always set. For a one-off reinstall during an ad-hoc run, use the `--refresh-cache` flag available on the run/test commands (`checkly test`, `checkly pw-test`, `checkly trigger`, `checkly checks run`) instead; the config value is the persistent knob that also applies to deployed, scheduled checks. - In Checkly CLI v8.0.0 and later, `include` patterns resolve relative to the Playwright config directory, not the project root. If `playwrightConfigPath` points to a subdirectory, adjust `include` globs. Example: `playwrightConfigPath: "./e2e/playwright.config.ts"` with a root fixture at `fixtures/data.json` needs `include: ["../fixtures/data.json"]`. -- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `bundle.packages.embed` in `checkly.config.ts` — a top-level section: `bundle: { packages: { embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'] } }`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error; a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml`, `package-lock.json`, the text `bun.lock` or a Yarn Berry `yarn.lock` — Yarn Classic v1 lockfiles are not supported), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc` (only `.npmrc` — bun or yarn users whose registry credentials live solely in `bunfig.toml` or `.yarnrc.yml` must duplicate them into `.npmrc`, or downloads fail with an auth error), verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Yarn Berry lockfiles record no npm tarball integrity (Berry checksums cover yarn's own cache format), so the CLI resolves the tarball integrity from the registry's package metadata instead — one small metadata request per embedded package on every deploy (the per-version route, falling back to the full packument), even when the tarballs themselves come from a warm cache, so a yarn embed needs registry reachability at deploy time even on a warm cache. When the bundled lockfile is pruned to the code bundle's contents (see the pruning bullet above), the embedded set follows it: packages the pruned lockfile no longer references — dependencies of workspace members that are not part of the bundle — are neither embedded nor downloaded, even if an entry matches them. If a package unexpectedly stops being embedded, the usual cause is that only a workspace member outside the bundle depends on it, in which case the runner never installs it and nothing is wrong; if the checks genuinely need it, make the depending member part of the bundle (import it from check code) rather than disabling pruning — `CHECKLY_LOCKFILE_PRUNE=0` restores the unfiltered set but reintroduces the over-describing lockfile that pruning exists to prevent, so treat it as a last resort. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache — but only for the tarballs actually shipped, not for pruned-away ones. Changing the resolved set of embedded packages invalidates the runner's dependency cache, so the next run reinstalls with the new tarballs. Applies to Playwright Check Suites only, not browser or multistep checks. +- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `bundle.packages.embed` in `checkly.config.ts` — a top-level section: `bundle: { packages: { embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'] } }`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); a `!` prefix (`!@acme/legacy`, `!@acme/*`, `!legacy@2.1.0`) turns an entry into an exclusion that removes the packages it matches from what the entries *before* it selected, so entries apply in order — `['@acme/*', '!@acme/legacy']` embeds the whole scope except `@acme/legacy`, while the reverse order embeds the whole scope because the exclusion runs before anything has been selected; as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error, except that exclusions never error (one that removes nothing is a no-op) and removing every package an earlier entry selected also silences that entry — no error, and no skip warning even for packages it matched but did not exclude, so use `DEBUG='checkly:cli:services:embedded-packages'` to see what such an entry reached; because exclusions only subtract, a list of nothing but `!` entries selects nothing, and a configuration whose entries select no packages at all is reported as a warning (packages dropped later by lockfile pruning are covered by the pruning note above); a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml`, `package-lock.json`, the text `bun.lock` or a Yarn Berry `yarn.lock` — Yarn Classic v1 lockfiles are not supported), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc` (only `.npmrc` — bun or yarn users whose registry credentials live solely in `bunfig.toml` or `.yarnrc.yml` must duplicate them into `.npmrc`, or downloads fail with an auth error), verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Yarn Berry lockfiles record no npm tarball integrity (Berry checksums cover yarn's own cache format), so the CLI resolves the tarball integrity from the registry's package metadata instead — one small metadata request per embedded package on every deploy (the per-version route, falling back to the full packument), even when the tarballs themselves come from a warm cache, so a yarn embed needs registry reachability at deploy time even on a warm cache. When the bundled lockfile is pruned to the code bundle's contents (see the pruning bullet above), the embedded set follows it: packages the pruned lockfile no longer references — dependencies of workspace members that are not part of the bundle — are neither embedded nor downloaded, even if an entry matches them. If a package unexpectedly stops being embedded, the usual cause is that only a workspace member outside the bundle depends on it, in which case the runner never installs it and nothing is wrong; if the checks genuinely need it, make the depending member part of the bundle (import it from check code) rather than disabling pruning — `CHECKLY_LOCKFILE_PRUNE=0` restores the unfiltered set but reintroduces the over-describing lockfile that pruning exists to prevent, so treat it as a last resort. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache — but only for the tarballs actually shipped, not for pruned-away ones. Changing the resolved set of embedded packages invalidates the runner's dependency cache, so the next run reinstalls with the new tarballs. Applies to Playwright Check Suites only, not browser or multistep checks. ## Install troubleshooting diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index aa352285..596c22ff 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -122,6 +122,20 @@ export type ChecklyConfig = { * transitive dependencies of other private packages — dependencies of * listed packages are not embedded automatically. * + * A `!` prefix turns an entry into an exclusion, which removes the + * packages it matches from what the entries *before* it selected. + * Entries therefore apply in order: `['@acme/*', '!@acme/legacy']` + * embeds the whole scope except `@acme/legacy`, while the reverse + * order embeds the whole scope, because the exclusion runs before + * anything has been selected. An exclusion that removes nothing is a + * no-op rather than an error. Removing every package an entry + * selected also silences that entry: no "not found" error, and no + * "cannot be embedded" warning even for packages it matched but did + * not exclude — run with `DEBUG='checkly:cli:services:embedded-packages'` + * to see what it reached. Since exclusions only subtract, a list of + * nothing but exclusions selects nothing; a configuration whose + * entries select no packages at all is reported as a warning. + * * Only npm, pnpm, bun and Yarn Berry are supported at this time: * packages are resolved against the workspace lockfile * (`pnpm-lock.yaml`, `package-lock.json`, the text `bun.lock` — From f37b20a5ef5ab98779c55a0757dcc055cf395ef5 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Tue, 25 Aug 2026 04:47:32 +0900 Subject: [PATCH 3/3] refactor(cli): extract the spec/package matching predicates [RED-892] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `name matches && (spec is unpinned || versions are equal)` test was spelled out at four points in the embedded-packages planner, once with the operands reversed, and the variant that also accepts a version-less entry was interleaved with it. Both are now named: specMatchesPackage() and specLooselyMatchesPackage(), alongside the existing specMatchesPackageName(). Naming them makes the distinction between the two sets explicit where it matters — a lockfile entry recorded without a version (a git resolution, a workspace link) matches any pin, so it can describe why a pinned entry failed but must not be what silences it. Behaviour is unchanged. The strict predicate implies the loose one, so the strict excluded set can now be filtered straight off the lockfile entries instead of being derived from the loose set; a unit test pins that implication, since the diagnostics rely on it and it is no longer structural. Co-Authored-By: Claude Opus 5 --- .../embedded-packages/__tests__/spec.spec.ts | 85 ++++++++++++++++++- .../embedded-packages/materializer.ts | 25 +++--- .../src/services/embedded-packages/spec.ts | 31 +++++++ 3 files changed, 128 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts index 824e6db0..189b8f35 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from 'vitest' -import { parseEmbeddedPackageSpec, InvalidEmbeddedPackageSpecError, specMatchesPackageName } from '../spec.js' +import { + parseEmbeddedPackageSpec, + InvalidEmbeddedPackageSpecError, + specLooselyMatchesPackage, + specMatchesPackage, + specMatchesPackageName, +} from '../spec.js' describe('parseEmbeddedPackageSpec()', () => { it('parses a bare package name', () => { @@ -192,3 +198,80 @@ describe('wildcard specs', () => { expect(() => parse('@/*')).toThrow(/not a valid npm package name pattern/) }) }) + +describe('specMatchesPackage()', () => { + it('matches on name alone when the spec has no pin', () => { + const spec = parseEmbeddedPackageSpec('some-package') + expect(specMatchesPackage(spec, { name: 'some-package', version: '1.0.0' })).toBe(true) + expect(specMatchesPackage(spec, { name: 'other-package', version: '1.0.0' })).toBe(false) + }) + + it('requires the exact version when the spec is pinned', () => { + const spec = parseEmbeddedPackageSpec('some-package@2.1.0') + expect(specMatchesPackage(spec, { name: 'some-package', version: '2.1.0' })).toBe(true) + expect(specMatchesPackage(spec, { name: 'some-package', version: '2.1.1' })).toBe(false) + }) + + it('never satisfies a pin with a version-less entry', () => { + // Git resolutions and workspace links are recorded without a version, so + // they have nothing to compare against a pin. + const entry = { name: 'some-package' } + expect(specMatchesPackage(parseEmbeddedPackageSpec('some-package@2.1.0'), entry)).toBe(false) + expect(specMatchesPackage(parseEmbeddedPackageSpec('some-package'), entry)).toBe(true) + }) + + it('applies wildcards through the compiled name pattern', () => { + const spec = parseEmbeddedPackageSpec('@acme/*@1.0.0') + expect(specMatchesPackage(spec, { name: '@acme/utils', version: '1.0.0' })).toBe(true) + expect(specMatchesPackage(spec, { name: '@acme/utils', version: '2.0.0' })).toBe(false) + expect(specMatchesPackage(spec, { name: '@other/utils', version: '1.0.0' })).toBe(false) + }) +}) + +describe('specLooselyMatchesPackage()', () => { + it('lets a version-less entry satisfy a pin', () => { + const spec = parseEmbeddedPackageSpec('some-package@2.1.0') + expect(specLooselyMatchesPackage(spec, { name: 'some-package' })).toBe(true) + // ...but a version that is present still has to match. + expect(specLooselyMatchesPackage(spec, { name: 'some-package', version: '2.1.0' })).toBe(true) + expect(specLooselyMatchesPackage(spec, { name: 'some-package', version: '2.1.1' })).toBe(false) + }) + + it('applies wildcards through the compiled name pattern', () => { + const spec = parseEmbeddedPackageSpec('@acme/*@1.0.0') + expect(specLooselyMatchesPackage(spec, { name: '@acme/utils' })).toBe(true) + expect(specLooselyMatchesPackage(spec, { name: '@other/utils' })).toBe(false) + }) + + it('still requires the name to match', () => { + const spec = parseEmbeddedPackageSpec('some-package@2.1.0') + expect(specLooselyMatchesPackage(spec, { name: 'other-package' })).toBe(false) + }) + + it('accepts everything the strict matcher accepts', () => { + // The planner reports a not-embeddable reason from the strict set but + // emits skip warnings from the loose one, so an entry the strict matcher + // takes must never fall outside the loose one. + const matched: string[] = [] + for (const raw of ['some-package', 'some-package@2.1.0', '@acme/*', '@acme/*@2.1.0']) { + const spec = parseEmbeddedPackageSpec(raw) + for (const entry of [ + { name: 'some-package' }, + { name: 'some-package', version: '2.1.0' }, + { name: 'some-package', version: '2.1.1' }, + { name: '@acme/utils' }, + { name: '@acme/utils', version: '2.1.0' }, + { name: '@other/utils', version: '2.1.0' }, + ]) { + if (!specMatchesPackage(spec, entry)) { + continue + } + matched.push(`${raw} ~ ${entry.name}@${entry.version}`) + expect(specLooselyMatchesPackage(spec, entry)).toBe(true) + } + } + // Without this the assertions above pass vacuously if the matrix stops + // matching anything. + expect(matched.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/cli/src/services/embedded-packages/materializer.ts b/packages/cli/src/services/embedded-packages/materializer.ts index 43a5172a..50e0094f 100644 --- a/packages/cli/src/services/embedded-packages/materializer.ts +++ b/packages/cli/src/services/embedded-packages/materializer.ts @@ -18,7 +18,10 @@ import { NpmrcConfig, defaultNpmrcPaths, loadNpmrcConfig, resolveAuthHeader, res import { EmbeddedPackageSpec, InvalidEmbeddedPackageSpecError, + PackageRef, parseEmbeddedPackageSpec, + specLooselyMatchesPackage, + specMatchesPackage, specMatchesPackageName, } from './spec.js' @@ -317,24 +320,22 @@ export class EmbeddedPackagesMaterializer { // step with what actually ships: an entry never warns about, or fails // over, a package the configuration goes on to exclude. const laterExclusions = specs.slice(index + 1).filter(other => other.exclude) - const kept = (entries: T[]) => entries.filter(entry => - !laterExclusions.some(other => specMatchesPackageName(other, entry.name) - && (other.version === undefined || other.version === entry.version))) + const kept = (entries: T[]) => + entries.filter(entry => !laterExclusions.some(other => specMatchesPackage(other, entry))) // nameMatches stays unfiltered: it only feeds the diagnostics below, // which describe the lockfile as it is — a mistyped pin should still be // told which versions exist, even when an unrelated exclusion removed // them from what this entry embeds. const nameMatches = packages.registry.filter(entry => specMatchesPackageName(spec, entry.name)) - const allCandidates = nameMatches - .filter(entry => spec.version === undefined || entry.version === spec.version) - const allLooseExcluded = relevantExcluded.filter(entry => specMatchesPackageName(spec, entry.name) - && (spec.version === undefined || entry.version === undefined || entry.version === spec.version)) - // The strict set drops version-less entries (workspace links, git - // resolutions), which match any pin and so can neither explain nor - // silence a pinned entry that simply named a version nothing has. - const allStrictExcluded = allLooseExcluded.filter(entry => - spec.version === undefined || entry.version === spec.version) + const allCandidates = packages.registry.filter(entry => specMatchesPackage(spec, entry)) + // The two sets differ only in version-less entries (workspace links, + // git resolutions), which the loose one keeps: such an entry matches + // any pin, so it can describe a pinned spec's failure but must not be + // what silences it. Both feed the diagnostics below, at different + // rungs of the ladder. + const allLooseExcluded = relevantExcluded.filter(entry => specLooselyMatchesPackage(spec, entry)) + const allStrictExcluded = relevantExcluded.filter(entry => specMatchesPackage(spec, entry)) const candidates = kept(allCandidates) const looseExcluded = kept(allLooseExcluded) diff --git a/packages/cli/src/services/embedded-packages/spec.ts b/packages/cli/src/services/embedded-packages/spec.ts index d955a06c..e6fb7bc7 100644 --- a/packages/cli/src/services/embedded-packages/spec.ts +++ b/packages/cli/src/services/embedded-packages/spec.ts @@ -31,6 +31,16 @@ export interface EmbeddedPackageSpec { exclude: boolean } +/** The parts of a lockfile entry a spec is matched against. */ +export interface PackageRef { + name: string + /** + * Absent on entries the lockfile records without one, e.g. git, file and + * URL resolutions and workspace links. + */ + version?: string +} + /** * Whether a spec selects the given package name: exact comparison for * plain specs, pattern match for wildcard specs. @@ -42,6 +52,27 @@ export function specMatchesPackageName (spec: EmbeddedPackageSpec, packageName: return spec.name === packageName } +/** + * Whether a spec selects the given package: the name must match, and so + * must an exact version pin. An entry the lockfile records without a + * version never satisfies a pin. + */ +export function specMatchesPackage (spec: EmbeddedPackageSpec, entry: PackageRef): boolean { + return specMatchesPackageName(spec, entry.name) + && (spec.version === undefined || entry.version === spec.version) +} + +/** + * As {@link specMatchesPackage}, except that a version-less entry matches + * any pin. Used where such an entry is still worth reporting against a + * pinned spec: a git resolution or a workspace link may well be the package + * the user meant, recorded in a form that has no version to compare. + */ +export function specLooselyMatchesPackage (spec: EmbeddedPackageSpec, entry: PackageRef): boolean { + return specMatchesPackageName(spec, entry.name) + && (spec.version === undefined || entry.version === undefined || entry.version === spec.version) +} + function compileNamePattern (name: string): RegExp { // Splitting on *runs* of `*` treats consecutive stars as one, keeping // the compiled regex free of adjacent `[^/]*` runs, whose backtracking