@@ -25,32 +25,69 @@ export function writeOne(sbom: RepositorySbom, { outDir, flatten = false }: Seri
2525 fs . writeFileSync ( filePath , JSON . stringify ( sbom , null , 2 ) , "utf8" ) ;
2626}
2727
28- export interface ReadOptions { flatten ?: boolean }
28+ export interface ReadOptions {
29+ /** Treat directory as flattened (owner-repo.json). */
30+ flatten ?: boolean ;
31+ /** Log parse errors (filename + message). */
32+ logParseErrors ?: boolean ;
33+ /** Stop after reading this many valid SBOMs (safety for huge trees). */
34+ maxFiles ?: number ;
35+ }
2936
30- export function readAll ( dir : string , _opts : ReadOptions = { } ) : RepositorySbom [ ] {
37+ /**
38+ * Read all serialized SBOMs below a directory.
39+ * Modes:
40+ * - Hierarchical (default): recurse and only accept files named exactly `sbom.json` (tightened from previous any *.json behavior).
41+ * - Flattened: do not recurse; accept top-level *.json where filename (without extension) contains at least one dash (owner-repo convention).
42+ */
43+ export function readAll ( dir : string , opts : ReadOptions = { } ) : RepositorySbom [ ] {
44+ const { flatten = false , logParseErrors = false , maxFiles } = opts ;
3145 const results : RepositorySbom [ ] = [ ] ;
3246 if ( ! fs . existsSync ( dir ) ) throw new Error ( `Directory not found: ${ dir } ` ) ;
3347
34- const visit = ( current : string ) => {
35- const stat = fs . statSync ( current ) ;
36- if ( stat . isDirectory ( ) ) {
37- const entries = fs . readdirSync ( current ) ;
38- for ( const e of entries ) visit ( path . join ( current , e ) ) ;
39- } else if ( stat . isFile ( ) ) {
40- const base = path . basename ( current ) ;
41- if ( base === "sbom.json" || base . endsWith ( ".json" ) ) {
42- try {
43- const raw = fs . readFileSync ( current , "utf8" ) ;
44- const obj = JSON . parse ( raw ) ;
45- if ( obj && obj . repo && Array . isArray ( obj . packages ) ) {
46- results . push ( obj as RepositorySbom ) ;
47- }
48- } catch ( e ) {
49- // skip malformed file
50- }
48+ const pushIfValid = ( filePath : string ) => {
49+ try {
50+ const raw = fs . readFileSync ( filePath , "utf8" ) ;
51+ const obj = JSON . parse ( raw ) ;
52+ if ( obj && obj . repo && Array . isArray ( obj . packages ) ) {
53+ results . push ( obj as RepositorySbom ) ;
54+ }
55+ } catch ( e ) {
56+ if ( logParseErrors ) {
57+ // eslint-disable-next-line no-console
58+ console . warn ( `Skipping malformed SBOM JSON ${ filePath } : ${ ( e as Error ) . message } ` ) ;
5159 }
5260 }
5361 } ;
54- visit ( dir ) ;
62+
63+ if ( flatten ) {
64+ const entries = fs . readdirSync ( dir , { withFileTypes : true } ) ;
65+ for ( const ent of entries ) {
66+ if ( ! ent . isFile ( ) ) continue ;
67+ if ( ! ent . name . endsWith ( '.json' ) ) continue ;
68+ // Require at least one dash to loosely match owner-repo.json (avoid pulling unrelated JSON)
69+ const baseName = ent . name . slice ( 0 , - 5 ) ; // remove .json
70+ if ( ! baseName . includes ( '-' ) ) continue ;
71+ pushIfValid ( path . join ( dir , ent . name ) ) ;
72+ if ( maxFiles && results . length >= maxFiles ) break ;
73+ }
74+ } else {
75+ const visit = ( current : string ) => {
76+ const stat = fs . statSync ( current ) ;
77+ if ( stat . isDirectory ( ) ) {
78+ const entries = fs . readdirSync ( current ) ;
79+ for ( const e of entries ) {
80+ visit ( path . join ( current , e ) ) ;
81+ if ( maxFiles && results . length >= maxFiles ) return ; // early exit
82+ }
83+ } else if ( stat . isFile ( ) ) {
84+ const base = path . basename ( current ) ;
85+ if ( base === "sbom.json" ) {
86+ pushIfValid ( current ) ;
87+ }
88+ }
89+ } ;
90+ visit ( dir ) ;
91+ }
5592 return results ;
5693}
0 commit comments