@@ -4,6 +4,7 @@ const crypto = require('node:crypto')
44const fs = require ( 'node:fs/promises' )
55const path = require ( 'node:path' )
66
7+ const { minimatch } = require ( 'minimatch' )
78const semver = require ( 'semver' )
89
910const constants = require ( '@socketregistry/scripts/constants' )
@@ -17,7 +18,10 @@ const {
1718const { readPackageJsonSync } = require ( '@socketsecurity/registry/lib/packages' )
1819const { readFileUtf8 } = require ( '@socketsecurity/registry/lib/fs' )
1920const { pEach } = require ( '@socketsecurity/registry/lib/promises' )
20- const { toSortedObject } = require ( '@socketsecurity/registry/lib/objects' )
21+ const {
22+ isObjectObject,
23+ toSortedObject
24+ } = require ( '@socketsecurity/registry/lib/objects' )
2125
2226const {
2327 LATEST ,
@@ -38,10 +42,95 @@ const registryPkg = packageData({
3842
3943const EXTRACT_PACKAGE_TMP_PREFIX = 'release-npm-'
4044
41- async function getPackageFileHashes ( spec ) {
45+ async function getLocalPackageFileHashes ( packagePath ) {
4246 const fileHashes = { }
4347
44- // Extract package to a temp directory and compute hashes.
48+ // Read package.json to get files field.
49+ const pkgJsonPath = path . join ( packagePath , PACKAGE_JSON )
50+ const pkgJsonContent = await readFileUtf8 ( pkgJsonPath )
51+ const pkgJson = JSON . parse ( pkgJsonContent )
52+ const filesPatterns = pkgJson . files || [ ]
53+
54+ // Always include package.json.
55+ const pkgJsonRelPath = PACKAGE_JSON
56+ const exportsValue = pkgJson . exports
57+ const relevantData = {
58+ dependencies : toSortedObject ( pkgJson . dependencies ?? { } ) ,
59+ exports : isObjectObject ( exportsValue )
60+ ? toSortedObject ( exportsValue )
61+ : ( exportsValue ?? null ) ,
62+ files : pkgJson . files ?? null ,
63+ sideEffects : pkgJson . sideEffects ?? null ,
64+ engines : pkgJson . engines ?? null
65+ }
66+ const pkgJsonHash = crypto
67+ . createHash ( 'sha256' )
68+ . update ( JSON . stringify ( relevantData ) , 'utf8' )
69+ . digest ( 'hex' )
70+ fileHashes [ pkgJsonRelPath ] = pkgJsonHash
71+
72+ // Walk and hash files.
73+ async function walkDir ( dir , baseDir = packagePath ) {
74+ const entries = await fs . readdir ( dir , { withFileTypes : true } )
75+ for ( const entry of entries ) {
76+ const fullPath = path . join ( dir , entry . name )
77+ const relativePath = path . relative ( baseDir , fullPath )
78+
79+ if ( entry . isDirectory ( ) ) {
80+ // Always recurse for patterns with ** or when we're at root level and have patterns.
81+ const shouldRecurse =
82+ relativePath === '' ||
83+ filesPatterns . some ( pattern => {
84+ return (
85+ pattern . includes ( '**' ) || pattern . startsWith ( relativePath + '/' )
86+ )
87+ } )
88+
89+ if ( shouldRecurse ) {
90+ // eslint-disable-next-line no-await-in-loop
91+ await walkDir ( fullPath , baseDir )
92+ }
93+ } else if ( entry . isFile ( ) && entry . name !== PACKAGE_JSON ) {
94+ // Check if file is npm auto-included (LICENSE/README with any case/extension in root).
95+ const isRootAutoIncluded =
96+ relativePath === entry . name && isNpmAutoIncluded ( entry . name )
97+
98+ // Check if file matches any of the patterns.
99+ const matchesPattern = filesPatterns . some ( pattern => {
100+ // Handle patterns like **/LICENSE{.original,}
101+ if ( pattern . includes ( '**' ) ) {
102+ const fileName = path . basename ( relativePath )
103+ const filePattern = pattern . replace ( '**/' , '' )
104+ return (
105+ minimatch ( fileName , filePattern ) ||
106+ minimatch ( relativePath , pattern )
107+ )
108+ }
109+ return minimatch ( relativePath , pattern )
110+ } )
111+
112+ if ( isRootAutoIncluded || matchesPattern ) {
113+ // eslint-disable-next-line no-await-in-loop
114+ const content = await readFileUtf8 ( fullPath )
115+ const hash = crypto
116+ . createHash ( 'sha256' )
117+ . update ( content , 'utf8' )
118+ . digest ( 'hex' )
119+ fileHashes [ relativePath ] = hash
120+ }
121+ }
122+ }
123+ }
124+
125+ await walkDir ( packagePath )
126+
127+ return toSortedObject ( fileHashes )
128+ }
129+
130+ async function getRemotePackageFileHashes ( spec ) {
131+ const fileHashes = { }
132+
133+ // Extract remote package and hash files.
45134 await extractPackage (
46135 spec ,
47136 {
@@ -59,15 +148,36 @@ async function getPackageFileHashes(spec) {
59148 // Recurse into subdirectories.
60149 // eslint-disable-next-line no-await-in-loop
61150 await walkDir ( fullPath , baseDir )
62- } else if ( entry . isFile ( ) && entry . name !== PACKAGE_JSON ) {
63- // Skip package.json files as they contain version info.
151+ } else if ( entry . isFile ( ) ) {
64152 // eslint-disable-next-line no-await-in-loop
65153 const content = await readFileUtf8 ( fullPath )
66- const hash = crypto
67- . createHash ( 'sha256' )
68- . update ( content , 'utf8' )
69- . digest ( 'hex' )
70- fileHashes [ relativePath ] = hash
154+
155+ if ( entry . name === PACKAGE_JSON ) {
156+ // For package.json, hash only relevant fields (not version).
157+ const pkgJson = JSON . parse ( content )
158+ const exportsValue = pkgJson . exports
159+ const relevantData = {
160+ dependencies : toSortedObject ( pkgJson . dependencies ?? { } ) ,
161+ exports : isObjectObject ( exportsValue )
162+ ? toSortedObject ( exportsValue )
163+ : ( exportsValue ?? null ) ,
164+ files : pkgJson . files ?? null ,
165+ sideEffects : pkgJson . sideEffects ?? null ,
166+ engines : pkgJson . engines ?? null
167+ }
168+ const hash = crypto
169+ . createHash ( 'sha256' )
170+ . update ( JSON . stringify ( relevantData ) , 'utf8' )
171+ . digest ( 'hex' )
172+ fileHashes [ relativePath ] = hash
173+ } else {
174+ // For other files, hash the entire content.
175+ const hash = crypto
176+ . createHash ( 'sha256' )
177+ . update ( content , 'utf8' )
178+ . digest ( 'hex' )
179+ fileHashes [ relativePath ] = hash
180+ }
71181 }
72182 }
73183 }
@@ -80,6 +190,8 @@ async function getPackageFileHashes(spec) {
80190}
81191
82192async function hasPackageChanged ( pkg , manifest_ ) {
193+ const { spinner } = constants
194+
83195 const manifest =
84196 manifest_ ?? ( await fetchPackageManifest ( `${ pkg . name } @${ pkg . tag } ` ) )
85197
@@ -89,63 +201,44 @@ async function hasPackageChanged(pkg, manifest_) {
89201 )
90202 }
91203
92- // First check if package.json version or dependencies have changed.
93- const localPkgJson = readPackageJsonSync ( pkg . path )
94-
95- // Check if dependencies have changed.
96- const localDeps = toSortedObject ( localPkgJson . dependencies ?? { } )
97- const remoteDeps = toSortedObject ( manifest . dependencies ?? { } )
98-
99- const localDepsStr = JSON . stringify ( localDeps )
100- const remoteDepsStr = JSON . stringify ( remoteDeps )
101-
102- // If dependencies changed, we need to bump.
103- if ( localDepsStr !== remoteDepsStr ) {
104- return true
105- }
106-
107- // Check if other important fields have changed.
108- const fieldsToCheck = [ 'exports' , 'files' , 'sideEffects' , 'engines' ]
109- for ( const field of fieldsToCheck ) {
110- const localValue = JSON . stringify ( localPkgJson [ field ] ?? null )
111- const remoteValue = JSON . stringify ( manifest [ field ] ?? null )
112- if ( localValue !== remoteValue ) {
113- return true
114- }
115- }
116-
117204 // Compare actual file contents by extracting packages and comparing SHA hashes.
118205 try {
119206 const { 0 : remoteHashes , 1 : localHashes } = await Promise . all ( [
120- getPackageFileHashes ( `${ pkg . name } @${ manifest . version } ` ) ,
121- getPackageFileHashes ( pkg . path , true )
207+ getRemotePackageFileHashes ( `${ pkg . name } @${ manifest . version } ` ) ,
208+ getLocalPackageFileHashes ( pkg . path )
122209 ] )
123210
124- // Compare the file hashes .
125- const remoteFiles = Object . keys ( remoteHashes )
126- const localFiles = Object . keys ( localHashes )
127-
128- // Check if file lists are different .
129- if ( JSON . stringify ( remoteFiles ) !== JSON . stringify ( localFiles ) ) {
130- return true
131- }
132-
133- // Check if any file content is different.
134- for ( const file of remoteFiles ) {
135- if ( remoteHashes [ file ] !== localHashes [ file ] ) {
211+ // Use remote files as source of truth and check if local matches .
212+ for ( const [ file , remoteHash ] of Object . entries ( remoteHashes ) ) {
213+ const localHash = localHashes [ file ]
214+ if ( ! localHash ) {
215+ // File exists in remote but not locally - this is a real difference .
216+ spinner ?. warn (
217+ ` ${ pkg . name } : File ' ${ file } ' exists in published package but not locally`
218+ )
219+ return true
220+ }
221+ if ( remoteHash !== localHash ) {
222+ spinner ?. info ( ` ${ pkg . name } : File ' ${ file } ' content differs` )
136223 return true
137224 }
138225 }
139226
140227 return false
141228 } catch ( e ) {
142229 // If comparison fails, be conservative and assume changes.
143- console . error ( `Error comparing packages for ${ pkg . name } :` , e ?. message )
230+ spinner ?. fail ( ` ${ pkg . name } : ${ e ?. message } ` )
144231 return true
145232 }
146233}
147234
148- async function maybeBumpPackage ( pkg , options = { } ) {
235+ function isNpmAutoIncluded ( fileName ) {
236+ const upperName = fileName . toUpperCase ( )
237+ // NPM automatically includes LICENSE and README files with any case and extension.
238+ return upperName . startsWith ( 'LICENSE' ) || upperName . startsWith ( 'README' )
239+ }
240+
241+ async function maybeBumpPackage ( pkg , options ) {
149242 const {
150243 spinner,
151244 state = {
@@ -169,7 +262,8 @@ async function maybeBumpPackage(pkg, options = {}) {
169262 // Compare the shasum of the @socketregistry the latest package from
170263 // registry.npmjs.org against the local version. If they are different
171264 // then bump the local version.
172- if ( await hasPackageChanged ( pkg , manifest ) ) {
265+ const hasChanged = await hasPackageChanged ( pkg , manifest )
266+ if ( hasChanged ) {
173267 let version = semver . inc ( manifest . version , 'patch' )
174268 if ( pkg . tag !== LATEST ) {
175269 version = `${ semver . inc ( version , 'patch' ) } -${ pkg . tag } `
0 commit comments