2525// and via its package specifier instantiates twice in one process — duplicate AppError, broken
2626// instanceof), and, for `scripts/` files which sit outside the type graph by design, the
2727// root-side unknown-package and exports-map checks that compiled files get from TS2307.
28- // On top, `TEST_ONLY_FIXTURE_SUBPATHS` mirrors R13's Android fixture gate for the non-platform
29- // packages: a production file importing one of those fixture subpaths is an R11 violation .
28+ // The consumer half of R83 (production code importing a test-only export or test-kit) is checked at
29+ // the same import sites, so the one specifier walk serves both rules .
3030
3131import fs from 'node:fs' ;
3232import path from 'node:path' ;
3333import { parseImports , resolveWorkspaceSpecifier } from './model.ts' ;
34+ import { PACKAGE_EXPORT_SURFACE_RULE , testOnlyImportMessage } from './package-export-surface.ts' ;
3435import { listTrackedPackageManifests , listTrackedProductionSources } from './tracked-sources.ts' ;
3536
3637export type PackageBoundaryViolation = {
@@ -102,13 +103,16 @@ export function specifierSites(file: string, source: string): SpecifierSite[] {
102103 * filtering the output.
103104 */
104105export function readWorkspacePackages ( repoRoot : string ) : WorkspacePackage [ ] {
105- return workspacePackagesFromManifests (
106- new Map (
107- listTrackedPackageManifests ( repoRoot ) . map ( ( manifestFile ) => [
108- manifestFile ,
109- fs . readFileSync ( path . join ( repoRoot , manifestFile ) , 'utf8' ) ,
110- ] ) ,
111- ) ,
106+ return workspacePackagesFromManifests ( readTrackedPackageManifests ( repoRoot ) ) ;
107+ }
108+
109+ /** Tracked workspace package manifests, text keyed by repo-relative path. */
110+ export function readTrackedPackageManifests ( repoRoot : string ) : Map < string , string > {
111+ return new Map (
112+ listTrackedPackageManifests ( repoRoot ) . map ( ( manifestFile ) => [
113+ manifestFile ,
114+ fs . readFileSync ( path . join ( repoRoot , manifestFile ) , 'utf8' ) ,
115+ ] ) ,
112116 ) ;
113117}
114118
@@ -173,45 +177,26 @@ function namedByExports(target: WorkspacePackage, specifier: string): boolean {
173177 return resolveWorkspaceSpecifier ( specifier , target . exportTargets ) !== undefined ;
174178}
175179
176- const TEST_CONSUMER_RE = / ( ^ | \/ ) _ _ t e s t s _ _ \/ | \. t e s t \. [ c m ] ? [ j t ] s x ? $ | (?: ^ | \/ ) t e s t \/ / ;
177-
178- /**
179- * The one file vocabulary for "this file consumes test data": the root `test/` tree, a
180- * package-local `test/` harness tree, `*.test.*` files, and `__tests__/` trees (root or
181- * nested). Both test-data gates call this — the `./testing/` façade check and the
182- * TEST_ONLY_FIXTURE_SUBPATHS predicate — so what counts as a test consumer is one rule,
183- * not two spellings that drift. Pinned by an accept/deny table in
184- * `package-boundaries-facade.test.ts`.
185- */
186- export function isTestConsumerFile ( file : string ) : boolean {
187- return TEST_CONSUMER_RE . test ( file ) ;
188- }
189-
190180/**
191- * Façade policy (ADR-0033 amendment): a package publishes test data only under its
192- * `./testing/` namespace — named `./testing/<fixture>` entries and per-folder
193- * `./testing/<dir>/*` patterns — and only test files may import it. The gate is the
194- * namespace, not a pattern entry: narrowing the surface to exact keys must not
195- * smuggle a fixture past the test-file rule. Production code that reaches a fixture
196- * through the package boundary is production code depending on a test double — it
197- * must inject the real port instead.
181+ * R83's consumer half (see package-export-surface.ts): a production file importing a test-only
182+ * export, or the private test-kit package, is production code depending on a test double — it
183+ * must use a production subpath or inject the real port.
198184 */
199- function checkTestDataOnlySites (
185+ function checkTestOnlyImportSite (
200186 site : SpecifierSite ,
201187 target : WorkspacePackage ,
202188) : PackageBoundaryViolation | undefined {
203- if ( ! site . specifier . startsWith ( `${ target . name } /testing/` ) ) return undefined ;
204- if ( ! namedByExports ( target , site . specifier ) ) return undefined ;
205- if ( isTestConsumerFile ( site . file ) ) return undefined ;
206- return {
207- rule : 'R11 package-boundaries' ,
208- file : site . file ,
209- line : site . line ,
210- message :
211- `'${ site . specifier } ' resolves through ${ target . dir } /package.json#exports './testing/' ` +
212- `namespace, which publishes test data to test files only. '${ site . file } ' is not a test ` +
213- `file: import a production subpath or inject the port.` ,
214- } ;
189+ const resolvedTarget = resolveWorkspaceSpecifier ( site . specifier , target . exportTargets ) ;
190+ const message = testOnlyImportMessage (
191+ site . file ,
192+ site . specifier ,
193+ target . name ,
194+ resolvedTarget === undefined
195+ ? undefined
196+ : { key : `.${ site . specifier . slice ( target . name . length ) } ` , targets : [ resolvedTarget ] } ,
197+ ) ;
198+ if ( message === undefined ) return undefined ;
199+ return { rule : PACKAGE_EXPORT_SURFACE_RULE , file : site . file , line : site . line , message } ;
215200}
216201
217202function specifierPackageName ( specifier : string ) : string | undefined {
@@ -243,21 +228,6 @@ export function insideCompiledSources(file: string): boolean {
243228 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 ) ;
244229}
245230
246- /**
247- * Workspace fixture subpaths only non-production files may import — the same claim R13's
248- * `ANDROID_TEST_ONLY_SUBPATHS` makes for the platform packages, which R13 cannot express for
249- * non-platform packages. A production file (root or package) importing one is a violation.
250- */
251- export const TEST_ONLY_FIXTURE_SUBPATHS : ReadonlySet < string > = new Set ( [
252- '@agent-device/device-selection/inventory-gateways-fixtures' ,
253- '@agent-device/replay-port/replay-script-source-fixtures' ,
254- ] ) ;
255-
256- /** Consumer side of `TEST_ONLY_FIXTURE_SUBPATHS`. */
257- function importsTestOnlyFixtureFromProduction ( file : string , specifier : string ) : boolean {
258- return TEST_ONLY_FIXTURE_SUBPATHS . has ( specifier ) && ! isTestConsumerFile ( file ) ;
259- }
260-
261231/**
262232 * Rules for files INSIDE a package. An undeclared sibling import is R11's alone: pnpm links
263233 * every workspace package under the root `node_modules`, so the import resolves, compiles, and
@@ -306,16 +276,6 @@ export function checkPackageInternalSites(
306276 }
307277 const name = specifierPackageName ( site . specifier ) ;
308278 if ( ! name || ! name . startsWith ( '@agent-device/' ) ) continue ;
309- if ( importsTestOnlyFixtureFromProduction ( site . file , site . specifier ) ) {
310- violations . push ( {
311- rule : 'R11 package-boundaries' ,
312- file : site . file ,
313- line : site . line ,
314- message :
315- `'${ site . specifier } ' is a test-only fixture subpath — production code may not ` +
316- `import it; use the package's production subpath instead.` ,
317- } ) ;
318- }
319279 const target = packageByName ( allPackages , name ) ;
320280 if ( ! target ) {
321281 if ( ! compiled ) {
@@ -338,7 +298,7 @@ export function checkPackageInternalSites(
338298 `in ${ pkg . dir } /package.json ${ pkg . workspaceDependencyField } .` ,
339299 } ) ;
340300 }
341- const testDataOnly = checkTestDataOnlySites ( site , target ) ;
301+ const testDataOnly = checkTestOnlyImportSite ( site , target ) ;
342302 if ( testDataOnly ) {
343303 violations . push ( testDataOnly ) ;
344304 continue ;
@@ -392,16 +352,6 @@ export function checkRootSites(
392352 }
393353 const name = specifierPackageName ( site . specifier ) ;
394354 if ( ! name || ! name . startsWith ( '@agent-device/' ) ) continue ;
395- if ( importsTestOnlyFixtureFromProduction ( site . file , site . specifier ) ) {
396- violations . push ( {
397- rule : 'R11 package-boundaries' ,
398- file : site . file ,
399- line : site . line ,
400- message :
401- `'${ site . specifier } ' is a test-only fixture subpath — production code may not ` +
402- `import it; use the package's production subpath instead.` ,
403- } ) ;
404- }
405355 const target = packageByName ( packages , name ) ;
406356 if ( ! target ) {
407357 if ( ! compiled ) {
@@ -424,7 +374,7 @@ export function checkRootSites(
424374 `package.json devDependencies.` ,
425375 } ) ;
426376 }
427- const testDataOnly = checkTestDataOnlySites ( site , target ) ;
377+ const testDataOnly = checkTestOnlyImportSite ( site , target ) ;
428378 if ( testDataOnly ) {
429379 violations . push ( testDataOnly ) ;
430380 continue ;
0 commit comments