11import { readFileSync , readdirSync } from 'node:fs' ;
2- import { join } from 'node:path' ;
2+ import { join , relative , sep } from 'node:path' ;
33
44const SRC = join ( __dirname , '..' ) ;
55const LAYERS = [ 'core' , 'transport' ] ;
66const RESOURCES = [ 'projects' , 'functions' , 'environments' , 'deployments' , 'git' ] ;
77
8+ const RESOURCE_EDGES : Record < string , string [ ] > = {
9+ projects : [ 'environments' , 'deployments' , 'git' ] ,
10+ functions : [ ] ,
11+ environments : [ ] ,
12+ deployments : [ ] ,
13+ git : [ ] ,
14+ } ;
15+
16+ function filesUnder ( directory : string ) : string [ ] {
17+ return readdirSync ( join ( SRC , directory ) , { withFileTypes : true } ) . flatMap ( ( entry ) => {
18+ const child = join ( directory , entry . name ) ;
19+
20+ if ( entry . isDirectory ( ) ) {
21+ return filesUnder ( child ) ;
22+ }
23+
24+ return entry . name . endsWith ( '.ts' ) ? [ child ] : [ ] ;
25+ } ) ;
26+ }
27+
828function sourceFilesIn ( layer : string ) : string [ ] {
9- return readdirSync ( join ( SRC , layer ) )
10- . filter ( ( entry ) => entry . endsWith ( '.ts' ) && ! entry . endsWith ( '.test.ts' ) )
11- . map ( ( entry ) => join ( layer , entry ) ) ;
29+ return filesUnder ( layer ) . filter ( ( path ) => ! path . endsWith ( '.test.ts' ) ) ;
1230}
1331
1432function importedPaths ( relativePath : string ) : string [ ] {
1533 const contents = readFileSync ( join ( SRC , relativePath ) , 'utf8' ) ;
1634 return [ ...contents . matchAll ( / f r o m ' ( [ ^ ' ] + ) ' / g) ] . map ( ( match ) => match [ 1 ] ) ;
1735}
1836
37+ function resourceOf ( specifier : string , fromDirectory : string ) : string | undefined {
38+ if ( ! specifier . startsWith ( '.' ) ) {
39+ return undefined ;
40+ }
41+
42+ const resolved = relative ( SRC , join ( SRC , fromDirectory , specifier ) ) ;
43+ const head = resolved . split ( sep ) [ 0 ] ;
44+
45+ return RESOURCES . includes ( head ) ? head : undefined ;
46+ }
47+
48+ function directoryOf ( relativePath : string ) : string {
49+ const parts = relativePath . split ( sep ) ;
50+ return parts . slice ( 0 , - 1 ) . join ( sep ) ;
51+ }
52+
53+ function crossResourceEdges ( relativePath : string ) : string [ ] {
54+ const directory = directoryOf ( relativePath ) ;
55+ const owner = directory . split ( sep ) [ 0 ] ;
56+
57+ return importedPaths ( relativePath )
58+ . map ( ( specifier ) => resourceOf ( specifier , directory ) )
59+ . filter ( ( target ) : target is string => target !== undefined && target !== owner ) ;
60+ }
61+
62+ function commandEdges ( relativePath : string ) : string [ ] {
63+ const owner = relativePath . split ( sep ) [ 2 ] ;
64+ const directory = directoryOf ( relativePath ) ;
65+
66+ return importedPaths ( relativePath )
67+ . map ( ( specifier ) => resourceOf ( specifier , directory ) )
68+ . filter ( ( target ) : target is string => target !== undefined && target !== owner ) ;
69+ }
70+
1971function resourceImports ( relativePath : string ) : string [ ] {
2072 return importedPaths ( relativePath ) . filter ( ( specifier ) =>
2173 RESOURCES . some ( ( resource ) => specifier . startsWith ( `../${ resource } /` ) || specifier === `../${ resource } ` ) ,
2274 ) ;
2375}
2476
2577describe ( 'layering' , ( ) => {
26- it . each ( LAYERS . flatMap ( ( layer ) => sourceFilesIn ( layer ) ) ) ( '%s imports no resource directly' , ( relativePath ) => {
78+ it . each ( LAYERS . flatMap ( ( layer ) => filesUnder ( layer ) ) ) ( '%s imports no resource directly' , ( relativePath ) => {
2779 expect ( resourceImports ( relativePath ) ) . toEqual ( [ ] ) ;
2880 } ) ;
2981
@@ -32,17 +84,91 @@ describe('layering', () => {
3284 expect ( sourceFilesIn ( 'transport' ) . length ) . toBeGreaterThan ( 0 ) ;
3385 } ) ;
3486
87+ it ( 'checks the test files in core and transport too, not only the production ones' , ( ) => {
88+ const checked = LAYERS . flatMap ( ( layer ) => filesUnder ( layer ) ) ;
89+
90+ expect ( checked ) . toContain ( join ( 'core' , 'layering.test.ts' ) ) ;
91+ expect ( checked . filter ( ( path ) => path . endsWith ( '.test.ts' ) ) . length ) . toBeGreaterThan ( 0 ) ;
92+ } ) ;
93+
3594 it ( 'reports a resource import when one is present, so the check cannot pass vacuously' , ( ) => {
3695 const contrived = importedPaths ( join ( 'projects' , 'project.inputs.ts' ) ) ;
3796
3897 expect ( contrived ) . toContain ( '../core/resolution' ) ;
39- expect (
40- contrived . filter ( ( specifier ) => RESOURCES . some ( ( resource ) => specifier . startsWith ( `../${ resource } /` ) ) ) ,
41- ) . toEqual ( [ ] ) ;
4298 expect (
4399 [ '../projects/types' , './types' ] . filter ( ( specifier ) =>
44100 RESOURCES . some ( ( resource ) => specifier . startsWith ( `../${ resource } /` ) ) ,
45101 ) ,
46102 ) . toEqual ( [ '../projects/types' ] ) ;
47103 } ) ;
48104} ) ;
105+
106+ describe ( 'layering between resources' , ( ) => {
107+ it . each ( RESOURCES . flatMap ( ( resource ) => filesUnder ( resource ) ) ) (
108+ '%s reaches only the resources its allow-list names' ,
109+ ( relativePath ) => {
110+ const owner = relativePath . split ( sep ) [ 0 ] ;
111+
112+ expect ( RESOURCE_EDGES [ owner ] ) . toBeDefined ( ) ;
113+ for ( const target of crossResourceEdges ( relativePath ) ) {
114+ expect ( RESOURCE_EDGES [ owner ] ) . toContain ( target ) ;
115+ }
116+ } ,
117+ ) ;
118+
119+ it ( 'declares an allow-list entry for every resource and invents none' , ( ) => {
120+ expect ( Object . keys ( RESOURCE_EDGES ) . sort ( ) ) . toEqual ( [ ...RESOURCES ] . sort ( ) ) ;
121+ for ( const targets of Object . values ( RESOURCE_EDGES ) ) {
122+ for ( const target of targets ) {
123+ expect ( RESOURCES ) . toContain ( target ) ;
124+ }
125+ }
126+ } ) ;
127+
128+ it ( 'records the edges POST /projects really creates, and no others' , ( ) => {
129+ expect ( RESOURCE_EDGES . projects ) . toEqual ( [ 'environments' , 'deployments' , 'git' ] ) ;
130+ expect ( crossResourceEdges ( join ( 'projects' , 'project.create.ts' ) ) . sort ( ) ) . toEqual ( [
131+ 'deployments' ,
132+ 'deployments' ,
133+ 'deployments' ,
134+ 'deployments' ,
135+ 'deployments' ,
136+ 'environments' ,
137+ 'environments' ,
138+ 'environments' ,
139+ 'environments' ,
140+ 'git' ,
141+ ] ) ;
142+ } ) ;
143+
144+ it ( 'reports a disallowed edge rather than passing vacuously' , ( ) => {
145+ const owner = 'git' ;
146+
147+ expect ( crossResourceEdges ( join ( 'projects' , 'project.create.ts' ) ) . length ) . toBeGreaterThan ( 0 ) ;
148+ expect ( RESOURCE_EDGES [ owner ] ) . not . toContain ( 'projects' ) ;
149+ expect ( resourceOf ( '../projects/types' , 'git' ) ) . toBe ( 'projects' ) ;
150+ } ) ;
151+ } ) ;
152+
153+ describe ( 'layering of the command files' , ( ) => {
154+ it . each ( filesUnder ( join ( 'commands' , 'launch' ) ) . filter ( ( path ) => path . split ( sep ) . length > 3 ) ) (
155+ '%s reaches its own resource and no other' ,
156+ ( relativePath ) => {
157+ expect ( commandEdges ( relativePath ) ) . toEqual ( [ ] ) ;
158+ } ,
159+ ) ;
160+
161+ it ( 'finds the command files it claims to be checking' , ( ) => {
162+ const checked = filesUnder ( join ( 'commands' , 'launch' ) ) . filter ( ( path ) => path . split ( sep ) . length > 3 ) ;
163+
164+ expect ( checked ) . toContain ( join ( 'commands' , 'launch' , 'projects' , 'create.ts' ) ) ;
165+ expect ( checked ) . toContain ( join ( 'commands' , 'launch' , 'functions' , 'serve.ts' ) ) ;
166+ } ) ;
167+
168+ it ( 'reports a command reaching another resource rather than passing vacuously' , ( ) => {
169+ expect ( resourceOf ( '../../../deployments/deployment.watcher' , join ( 'commands' , 'launch' , 'projects' ) ) ) . toBe (
170+ 'deployments' ,
171+ ) ;
172+ expect ( resourceOf ( '../../../projects/project.create' , join ( 'commands' , 'launch' , 'projects' ) ) ) . toBe ( 'projects' ) ;
173+ } ) ;
174+ } ) ;
0 commit comments