1515 * name something outside its allow-list fails even if no source file imports
1616 * it yet. (`devDependencies` are tooling and are not constrained.) Manifest
1717 * checking resolves the dependency VALUE — `npm:` aliases, `link:`/`file:`/
18- * `portal:` local paths, `workspace:` aliases — because the key is not the
19- * package that gets installed.
18+ * `portal:` local paths, `workspace:` aliases and `workspace:` relative paths
19+ * — because the key is not the package that gets installed. Specifier forms
20+ * whose target cannot be determined from the repo (`catalog:`, git specs,
21+ * tarball URLs, `jsr:`, unknown protocols) are reported as violations rather
22+ * than falling back to the key, since falling back to the key IS the bypass.
2023 *
2124 * Also forbids, everywhere in the repo, deep imports into another package's
2225 * src (e.g. `@layoutit/polycss-core/src/...` or `../../packages/x/src/...`)
@@ -128,20 +131,70 @@ function readLocalPackageName(packageDir, relPath) {
128131 }
129132}
130133
134+ /**
135+ * A `workspace:` / `link:` target that is a path rather than a package name.
136+ * `workspace:../polycss` and `workspace:packages/polycss` both mount a
137+ * different local package under the declared key; only a leading `@` marks a
138+ * scoped package NAME rather than a path.
139+ */
140+ const isLocalPathSpec = ( rest ) =>
141+ rest . startsWith ( "." ) ||
142+ rest . startsWith ( "/" ) ||
143+ rest . startsWith ( "~/" ) ||
144+ ( rest . includes ( "/" ) && ! rest . startsWith ( "@" ) ) ;
145+
146+ /**
147+ * A bare semver range or range operator, which leaves the key as the installed
148+ * package: `^`, `*`, `~`, `1.2.3`, `^0.2.0`, `>=1 <2`, `1.x`. Anything else
149+ * after `workspace:` is a package name — with or without an `@range` suffix.
150+ */
151+ const isVersionRange = ( rest ) => / ^ [ \s * ^ ~ > < = v \d . | x X + - ] + $ / . test ( rest ) ;
152+
153+ /** Specifier protocols whose install target this checker cannot determine. */
154+ const UNRESOLVABLE_PROTOCOLS = new Map ( [
155+ [
156+ "catalog" ,
157+ "is a pnpm catalog reference whose target lives in pnpm-workspace.yaml" ,
158+ ] ,
159+ [
160+ "jsr" ,
161+ "is a JSR specifier, which installs under a rewritten npm name (@jsr/…)" ,
162+ ] ,
163+ [ "git" , "is a git specifier, whose installed package name is in the repo" ] ,
164+ [ "github" , "is a git specifier, whose installed package name is in the repo" ] ,
165+ [ "gitlab" , "is a git specifier, whose installed package name is in the repo" ] ,
166+ [
167+ "bitbucket" ,
168+ "is a git specifier, whose installed package name is in the repo" ,
169+ ] ,
170+ [ "gist" , "is a git specifier, whose installed package name is in the repo" ] ,
171+ [ "http" , "is a remote tarball, whose package name is inside the tarball" ] ,
172+ [ "https" , "is a remote tarball, whose package name is inside the tarball" ] ,
173+ ] ) ;
174+
131175/**
132176 * The manifest KEY is not the package that gets installed. `npm:` aliases,
133- * `link:`/`file:`/`portal:` local paths and `workspace:<name>@<range>` aliases
134- * all mount a DIFFERENT package under the declared key, so
135- * `"@layoutit/polycss-core": "npm:@layoutit/polycss@0.2.0"` would pass a
136- * key-only allow-list while installing the forbidden graph. The allow-list is
137- * therefore applied to the resolved target, not the key.
177+ * `link:`/`file:`/`portal:` local paths, `workspace:<name>[ @<range>] ` aliases
178+ * and `workspace:<path>` targets all mount a DIFFERENT package under the
179+ * declared key, so `"@layoutit/polycss-core": "npm:@layoutit/polycss@0.2.0"`
180+ * would pass a key-only allow-list while installing the forbidden graph. The
181+ * allow-list is therefore applied to the resolved target, not the key.
138182 *
139- * A local path whose target cannot be read is `unresolved` rather than
140- * allowed: an unverifiable target is not a permitted one.
183+ * Anything this function cannot resolve is `unresolved`, which the caller
184+ * turns into a violation. That is deliberate: an unverifiable target is not a
185+ * permitted one, and the alternative — falling back to the key — is precisely
186+ * the bypass. Catalog references, git specs and tarball URLs therefore FAIL
187+ * with an explanation rather than passing; a package that needs one must
188+ * declare it in a form the checker can see through.
141189 */
142190export function resolveDependencyTarget ( name , spec , options = { } ) {
143- if ( typeof spec !== "string" ) return { name } ;
191+ if ( typeof spec !== "string" ) {
192+ return { unresolved : "has a non-string version specifier" } ;
193+ }
144194 const value = spec . trim ( ) ;
195+ if ( value . length === 0 ) {
196+ return { unresolved : "has an empty version specifier" } ;
197+ }
145198
146199 if ( value . startsWith ( "npm:" ) ) {
147200 const target = splitNameAtRange ( value . slice ( "npm:" . length ) ) ;
@@ -164,13 +217,47 @@ export function resolveDependencyTarget(name, spec, options = {}) {
164217
165218 if ( value . startsWith ( "workspace:" ) ) {
166219 const rest = value . slice ( "workspace:" . length ) ;
167- // `workspace:^`, `workspace:*`, `workspace:1.2.3` keep the key; only the
168- // `workspace:<other-name>@<range>` alias form retargets it.
169- if ( / ^ [ @ a - z A - Z ] / . test ( rest ) && rest . includes ( "@" , 1 ) ) {
170- const target = splitNameAtRange ( rest ) ;
171- if ( target ) return { name : target , via : `workspace alias "${ value } "` } ;
220+ if ( rest . length === 0 ) {
221+ return { unresolved : 'has an empty "workspace:" specifier' } ;
172222 }
173- return { name } ;
223+ if ( isLocalPathSpec ( rest ) ) {
224+ const target = readLocalPackageName ( options . packageDir , rest ) ;
225+ return target
226+ ? { name : target , via : `workspace path target ${ rest } ` }
227+ : {
228+ unresolved :
229+ `points at workspace path "${ value } ", whose package.json name ` +
230+ "could not be read" ,
231+ } ;
232+ }
233+ // `workspace:^`, `workspace:*`, `workspace:1.2.3` keep the key; every
234+ // other form names a package, whether or not it carries an `@range`.
235+ if ( isVersionRange ( rest ) ) return { name } ;
236+ const target = splitNameAtRange ( rest ) ;
237+ return target
238+ ? { name : target , via : `workspace alias "${ value } "` }
239+ : { unresolved : `has an unparseable workspace alias "${ value } "` } ;
240+ }
241+
242+ const protocol = / ^ ( [ a - z A - Z ] [ a - z A - Z 0 - 9 + . - ] * ) : / . exec ( value ) ;
243+ if ( protocol ) {
244+ const base = protocol [ 1 ] . toLowerCase ( ) . split ( "+" ) [ 0 ] ;
245+ const known = UNRESOLVABLE_PROTOCOLS . get ( base ) ;
246+ return {
247+ unresolved : known
248+ ? `${ known } , so the installed package cannot be verified here`
249+ : `uses an unrecognised specifier protocol "${ protocol [ 1 ] } :"` ,
250+ } ;
251+ }
252+
253+ // npm's `owner/repo` shorthand is a git dependency. A semver range or a
254+ // dist-tag never contains a slash, so this is unambiguous.
255+ if ( value . includes ( "/" ) ) {
256+ return {
257+ unresolved :
258+ `looks like a git shorthand ("${ value } "), whose installed package ` +
259+ "name is inside the repository" ,
260+ } ;
174261 }
175262
176263 return { name } ;
0 commit comments