@@ -37,7 +37,11 @@ import {
3737 resolveAllConditionTargets ,
3838 testOnlyImportMessage ,
3939} from './package-export-surface.ts' ;
40- import { listTrackedPackageManifests , listTrackedProductionSources } from './tracked-sources.ts' ;
40+ import {
41+ isProductionSourceFile ,
42+ listTrackedPackageManifests ,
43+ listTrackedProductionSources ,
44+ } from './tracked-sources.ts' ;
4145
4246export type PackageBoundaryViolation = {
4347 rule : string ;
@@ -56,6 +60,13 @@ export type WorkspacePackage = {
5660 exportConditionTargets ?: ReadonlyMap < string , readonly string [ ] > ;
5761 /** Declared `workspace:*` dependencies on sibling internal packages. */
5862 workspaceDependencies : ReadonlySet < string > ;
63+ /**
64+ * Workspace siblings a private package declares in `devDependencies`. Only test support (tests,
65+ * fixtures, test trees) may import through them; production source still needs
66+ * `workspaceDependencyField`. A published package keeps every sibling in `devDependencies`
67+ * already, so this is empty there.
68+ */
69+ workspaceDevDependencies ?: ReadonlySet < string > ;
5970 /** Where siblings are declared: a published package bundles them, so it lists them as dev-only. */
6071 workspaceDependencyField : 'dependencies' | 'devDependencies' ;
6172 /** Non-workspace dependencies that the root build must externalize. */
@@ -167,6 +178,13 @@ export function workspacePackagesFromManifests(
167178 . filter ( ( [ , range ] ) => range . startsWith ( 'workspace:' ) )
168179 . map ( ( [ name ] ) => name ) ,
169180 ) ;
181+ const workspaceDevDependencies = new Set (
182+ manifest . private === true
183+ ? Object . entries ( manifest . devDependencies ?? { } )
184+ . filter ( ( [ , range ] ) => range . startsWith ( 'workspace:' ) )
185+ . map ( ( [ name ] ) => name )
186+ : [ ] ,
187+ ) ;
170188 const externalDependencies = new Map (
171189 Object . entries ( manifest . dependencies ?? { } ) . filter (
172190 ( [ , range ] ) => ! range . startsWith ( 'workspace:' ) ,
@@ -178,6 +196,7 @@ export function workspacePackagesFromManifests(
178196 exportTargets,
179197 exportConditionTargets : conditionTargets ,
180198 workspaceDependencies,
199+ workspaceDevDependencies,
181200 workspaceDependencyField,
182201 externalDependencies,
183202 } ) ;
@@ -252,6 +271,29 @@ export function insideCompiledSources(file: string): boolean {
252271 return / ^ p a c k a g e s \/ [ ^ / ] + \/ s r c \/ / . test ( file ) || / ^ s r c \/ / . test ( file ) || / ^ t e s t \/ / . test ( file ) ;
253272}
254273
274+ /**
275+ * A sibling import must be declared `workspace:*` in the package manifest. Production source needs
276+ * the production field (`dependencies`, or `devDependencies` for a published package, which
277+ * bundles its siblings); test support — tests, `*.fixtures.*` files, test trees, test-kit — may
278+ * also resolve through a private package's `devDependencies`, so a test-only sibling such as
279+ * test-kit never ships as a runtime dependency.
280+ */
281+ function undeclaredSiblingMessage (
282+ pkg : WorkspacePackage ,
283+ name : string ,
284+ site : SpecifierSite ,
285+ ) : string | undefined {
286+ if ( name === pkg . name || pkg . workspaceDependencies . has ( name ) ) return undefined ;
287+ const declaredForTests = pkg . workspaceDevDependencies ?. has ( name ) ?? false ;
288+ if ( declaredForTests && ! isProductionSourceFile ( site . file ) ) return undefined ;
289+ return declaredForTests
290+ ? `${ pkg . name } imports '${ site . specifier } ' from production source, but ${ pkg . dir } /package.json ` +
291+ `declares "${ name } " only in devDependencies: declare it in ${ pkg . workspaceDependencyField } ` +
292+ `or import it from test support only.`
293+ : `${ pkg . name } imports '${ site . specifier } ' without declaring "${ name } ": "workspace:*" ` +
294+ `in ${ pkg . dir } /package.json ${ pkg . workspaceDependencyField } .` ;
295+ }
296+
255297/**
256298 * Rules for files INSIDE a package. An undeclared sibling import is R11's alone: pnpm links
257299 * every workspace package under the root `node_modules`, so the import resolves, compiles, and
@@ -312,14 +354,13 @@ export function checkPackageInternalSites(
312354 }
313355 continue ;
314356 }
315- if ( name !== pkg . name && ! pkg . workspaceDependencies . has ( name ) ) {
357+ const undeclared = undeclaredSiblingMessage ( pkg , name , site ) ;
358+ if ( undeclared ) {
316359 violations . push ( {
317360 rule : 'R11 package-boundaries' ,
318361 file : site . file ,
319362 line : site . line ,
320- message :
321- `${ pkg . name } imports '${ site . specifier } ' without declaring "${ name } ": "workspace:*" ` +
322- `in ${ pkg . dir } /package.json ${ pkg . workspaceDependencyField } .` ,
363+ message : undeclared ,
323364 } ) ;
324365 }
325366 const testDataOnly = checkTestOnlyImportSite ( site , target ) ;
0 commit comments