From f46c7e487cdcf63b42db07f97006842f1e4ac084 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 19 Aug 2026 10:13:13 -0600 Subject: [PATCH 1/3] fix: prevent ENOTDIR crash when non-component files exist in bundle type directories (W-23924917) When a non-component file (e.g., README.md, .DS_Store) sits directly inside a bundle type directory like lwc/, retrieve/deploy commands crash with ENOTDIR because the file path is passed to readDirectory(). Three complementary guards: - MixedContentSourceAdapter.getRootMetadataXmlPath: check isDirectory before calling tree.find() on the computed component root - BundleSourceAdapter.populate: skip files whose trimPathToContent resolves to a non-directory (i.e., files at the type directory level rather than inside a component folder) - NodeFSTreeContainer.readDirectory: return [] for non-directory paths instead of letting readdirSync throw ENOTDIR --- src/resolve/adapters/bundleSourceAdapter.ts | 15 +++++-- .../adapters/mixedContentSourceAdapter.ts | 1 + src/resolve/treeContainers.ts | 4 +- .../adapters/bundleSourceAdapter.test.ts | 40 ++++++++++++++++++- test/resolve/metadataResolver.test.ts | 13 ++++++ test/resolve/treeContainers.test.ts | 10 +++++ 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/resolve/adapters/bundleSourceAdapter.ts b/src/resolve/adapters/bundleSourceAdapter.ts index 0728ddcbf7..44d72a03ff 100644 --- a/src/resolve/adapters/bundleSourceAdapter.ts +++ b/src/resolve/adapters/bundleSourceAdapter.ts @@ -57,9 +57,18 @@ export class BundleSourceAdapter extends MixedContentSourceAdapter { * @protected */ protected populate(trigger: SourcePath, component?: SourceComponent): SourceComponent | undefined { - if (this.tree.isDirectory(trigger) && !this.tree.readDirectory(trigger)?.length) { - // if it's an empty directory, don't include it (e.g., lwc/emptyLWC) - return; + if (this.tree.isDirectory(trigger)) { + if (!this.tree.readDirectory(trigger)?.length) { + // if it's an empty directory, don't include it (e.g., lwc/emptyLWC) + return; + } + } else if (!component) { + const componentRoot = this.trimPathToContent(trigger); + if (!this.tree.isDirectory(componentRoot)) { + // the file sits directly inside the type directory (e.g., lwc/README.md) + // rather than inside a component bundle folder — it is not a valid component + return; + } } return super.populate(trigger, component); } diff --git a/src/resolve/adapters/mixedContentSourceAdapter.ts b/src/resolve/adapters/mixedContentSourceAdapter.ts index c6fba24859..efc079717c 100644 --- a/src/resolve/adapters/mixedContentSourceAdapter.ts +++ b/src/resolve/adapters/mixedContentSourceAdapter.ts @@ -55,6 +55,7 @@ export class MixedContentSourceAdapter extends BaseSourceAdapter { protected getRootMetadataXmlPath(trigger: SourcePath): SourcePath | undefined { if (this.ownFolder) { const componentRoot = this.trimPathToContent(trigger); + if (!this.tree.isDirectory(componentRoot)) return undefined; return this.tree.find('metadataXml', basename(componentRoot), componentRoot); } return this.findMetadataFromContent(trigger); diff --git a/src/resolve/treeContainers.ts b/src/resolve/treeContainers.ts index e67151364d..e0a8e6fba7 100644 --- a/src/resolve/treeContainers.ts +++ b/src/resolve/treeContainers.ts @@ -124,7 +124,9 @@ export class NodeFSTreeContainer extends TreeContainer { } public readDirectory(fsPath: SourcePath): string[] { - return readdirSync(this.getUpdatedFsPath(fsPath)); + const resolved = this.getUpdatedFsPath(fsPath); + if (!statSync(resolved).isDirectory()) return []; + return readdirSync(resolved); } public readFile(fsPath: SourcePath): Promise { diff --git a/test/resolve/adapters/bundleSourceAdapter.test.ts b/test/resolve/adapters/bundleSourceAdapter.test.ts index 688c970a4e..44d4088d13 100644 --- a/test/resolve/adapters/bundleSourceAdapter.test.ts +++ b/test/resolve/adapters/bundleSourceAdapter.test.ts @@ -14,12 +14,13 @@ * limitations under the License. */ +import { join } from 'node:path'; import { expect } from 'chai'; import { bundle, lwcBundle } from '../../mock'; import { BundleSourceAdapter } from '../../../src/resolve/adapters'; import { CONTENT_PATH } from '../../mock/type-constants/auraBundleConstant'; import { CONTENT_PATH as LWC_CONTENT_PATH } from '../../mock/type-constants/lwcBundleConstant'; -import { RegistryAccess } from '../../../src'; +import { RegistryAccess, registry, VirtualTreeContainer } from '../../../src'; describe('BundleSourceAdapter with AuraBundle', () => { const registryAccess = new RegistryAccess(); @@ -78,4 +79,41 @@ describe('BundleSourceAdapter with AuraBundle', () => { expect(emptyBundleAdapter.getComponent(LWC_CONTENT_PATH)).to.be.undefined; }); }); + + describe('non-component files in bundle type directory', () => { + const type = registry.types.lightningcomponentbundle; + const typeDir = join('force-app', 'main', 'default', type.directoryName); + const readmePath = join(typeDir, 'README.md'); + const dsStorePath = join(typeDir, '.DS_Store'); + const componentName = 'validCmp'; + const componentDir = join(typeDir, componentName); + + const tree = new VirtualTreeContainer([ + { + dirPath: typeDir, + children: [componentName, 'README.md', '.DS_Store'], + }, + { + dirPath: componentDir, + children: [`${componentName}.js`, `${componentName}.js-meta.xml`], + }, + ]); + + const adapter = new BundleSourceAdapter(type, registryAccess, undefined, tree); + + it('Should return undefined for a non-component file (README.md) in lwc/', () => { + expect(adapter.getComponent(readmePath)).to.be.undefined; + }); + + it('Should return undefined for a dot file (.DS_Store) in lwc/', () => { + expect(adapter.getComponent(dsStorePath)).to.be.undefined; + }); + + it('Should still resolve a valid component directory', () => { + const result = adapter.getComponent(componentDir); + expect(result).to.not.be.undefined; + expect(result?.type).to.deep.equal(type); + expect(result?.name).to.equal(componentName); + }); + }); }); diff --git a/test/resolve/metadataResolver.test.ts b/test/resolve/metadataResolver.test.ts index 8ab5ac9732..29ccca4652 100644 --- a/test/resolve/metadataResolver.test.ts +++ b/test/resolve/metadataResolver.test.ts @@ -552,6 +552,19 @@ describe('MetadataResolver', () => { expect(mdResolver.getComponentsFromPath(nonMetadataDirPath, filter)).to.deep.equal([]); }); + it('Should return empty array for a non-component file directly inside a bundle type directory (lwc/)', () => { + const lwcDir = join('force-app', 'main', 'default', 'lwc'); + const readmePath = join(lwcDir, 'README.md'); + const cmpDir = join(lwcDir, 'myCmp'); + const treeContainer = new VirtualTreeContainer([ + { dirPath: join('force-app', 'main', 'default'), children: ['lwc'] }, + { dirPath: lwcDir, children: ['myCmp', 'README.md'] }, + { dirPath: cmpDir, children: ['myCmp.js', 'myCmp.js-meta.xml'] }, + ]); + const mdResolver = new MetadataResolver(undefined, treeContainer, false); + expect(mdResolver.getComponentsFromPath(readmePath)).to.deep.equal([]); + }); + it('Should resolve RestrictionRules metadata in mdapi format', () => { const unpackagedPath = 'unpackaged'; const packageXmlPath = join(unpackagedPath, 'package.xml'); diff --git a/test/resolve/treeContainers.test.ts b/test/resolve/treeContainers.test.ts index b8863c9535..dd8df33c47 100644 --- a/test/resolve/treeContainers.test.ts +++ b/test/resolve/treeContainers.test.ts @@ -109,6 +109,9 @@ describe('Tree Containers', () => { }); it('should use expected Node API for readDirectory', () => { + const statStub = env.stub(fs, 'statSync'); + // @ts-ignore lstat returns more than isDirectory function + statStub.withArgs(path).returns({ isDirectory: () => true }); const readdirStub = env.stub(fs, 'readdirSync'); // @ts-ignore wants Dirents but string[] works as well readdirStub.withArgs(path).returns(readDirResults); @@ -116,6 +119,13 @@ describe('Tree Containers', () => { expect(readdirStub.calledOnce).to.be.true; }); + it('should return empty array for readDirectory on a file path', () => { + const statStub = env.stub(fs, 'statSync'); + // @ts-ignore lstat returns more than isDirectory function + statStub.withArgs(path).returns({ isDirectory: () => false }); + expect(tree.readDirectory(path)).to.deep.equal([]); + }); + it('should use expected Node API for readFile', async () => { const readFileStub = env.stub(fs, 'readFileSync'); // @ts-ignore wants Dirents but string[] works as well From 13c488e2832834acde3eba649906d75b71a7818e Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 19 Aug 2026 10:19:05 -0600 Subject: [PATCH 2/3] chore: bump core --- package.json | 2 +- yarn.lock | 27 +++++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 80fedd761e..d15c355a28 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "node": ">=22.0.0" }, "dependencies": { - "@salesforce/core": "^9.0.0", + "@salesforce/core": "^9.1.4", "@salesforce/kit": "^4.0.0", "@salesforce/ts-types": "^3.0.0", "@salesforce/types": "^1.6.0", diff --git a/yarn.lock b/yarn.lock index 4cf7cb0f97..207f3cb645 100644 --- a/yarn.lock +++ b/yarn.lock @@ -661,7 +661,7 @@ node-fetch "^2.6.1" xml2js "^0.6.2" -"@jsforce/jsforce-node@^3.10.17", "@jsforce/jsforce-node@^3.10.19": +"@jsforce/jsforce-node@^3.10.19": version "3.10.19" resolved "https://registry.yarnpkg.com/@jsforce/jsforce-node/-/jsforce-node-3.10.19.tgz#ccbc539c12f4f7dff9cfdcc6cfb8f07bd840f731" integrity sha512-k7i2Tntu1fLvkMtRcKDFU64/Fr2M692ECtbwIGX6hcOh5mj+jrMa1tlvcdwffxAMl+lPYCXnY2bjErxWmP84zA== @@ -676,6 +676,21 @@ undici "^8.5.0" xml2js "^0.6.2" +"@jsforce/jsforce-node@^3.10.22": + version "3.10.22" + resolved "https://registry.yarnpkg.com/@jsforce/jsforce-node/-/jsforce-node-3.10.22.tgz#b15d0bfa8280dff3d2b6d5a833a6febb6bef138c" + integrity sha512-4TLjnvTlBW59NmNSsRRV5dDEepQGfBKVD0WWQlJUJwkU0d5FFxo8GbfNmCOXkjjYAe1wv7SuvMzJKcLZHU6qyw== + dependencies: + "@sindresorhus/is" "^4" + base64url "^3.0.1" + csv-parse "^5.5.2" + csv-stringify "^6.6.0" + faye "^1.4.0" + form-data "^4.0.4" + multistream "^3.1.0" + undici "^8.5.0" + xml2js "^0.6.2" + "@jsonjoy.com/base64@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-1.1.2.tgz#cf8ea9dcb849b81c95f14fc0aaa151c6b54d2578" @@ -798,12 +813,12 @@ ts-retry-promise "^0.8.1" zod "^4.1.12" -"@salesforce/core@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-9.0.0.tgz#bf7a2816a322b8febc5349f5a1c884b8be073b06" - integrity sha512-sL4sr8MXcdsHZJr0bacMY0ZJmbPwBEvJudDIlKxKdQu/62d1aNBvACPFfL32QJdu2sOkEpgF3CX/0znkU43g2g== +"@salesforce/core@^9.1.4": + version "9.1.4" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-9.1.4.tgz#264a618f962b7306794d5a7a42698bdb7dec6d04" + integrity sha512-S4VZ0xstYOAs5dwt7EDGkuFZA8rddy0wmuPTGjsIhgAPzuq3I5lKyBNwqXMMxdhBcWi+P4cXccHLpOYivgdrXQ== dependencies: - "@jsforce/jsforce-node" "^3.10.17" + "@jsforce/jsforce-node" "^3.10.22" "@salesforce/kit" "^4.0.0" "@salesforce/ts-types" "^3.0.0" ajv "^8.18.0" From c16bffd8624bbaf19edd00221cfe50e269666209 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Mon, 24 Aug 2026 16:57:19 -0600 Subject: [PATCH 3/3] chore: fix linting --- eslint-suppressions.json | 5 +-- .../adapters/bundleSourceAdapter.test.ts | 13 ++++-- test/resolve/treeContainers.test.ts | 44 +++++++++---------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a9bab07268..a76f9b445f 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -334,11 +334,8 @@ } }, "test/resolve/treeContainers.test.ts": { - "@typescript-eslint/ban-ts-comment": { - "count": 5 - }, "@typescript-eslint/no-shadow": { - "count": 5 + "count": 1 } }, "test/snapshot/helper/conversions.ts": { diff --git a/test/resolve/adapters/bundleSourceAdapter.test.ts b/test/resolve/adapters/bundleSourceAdapter.test.ts index 44d4088d13..39638579d2 100644 --- a/test/resolve/adapters/bundleSourceAdapter.test.ts +++ b/test/resolve/adapters/bundleSourceAdapter.test.ts @@ -24,14 +24,19 @@ import { RegistryAccess, registry, VirtualTreeContainer } from '../../../src'; describe('BundleSourceAdapter with AuraBundle', () => { const registryAccess = new RegistryAccess(); - const adapter = new BundleSourceAdapter(bundle.COMPONENT.type, registryAccess, undefined, bundle.COMPONENT.tree); + const sharedAdapter = new BundleSourceAdapter( + bundle.COMPONENT.type, + registryAccess, + undefined, + bundle.COMPONENT.tree + ); it('Should return expected SourceComponent when given a root metadata xml path', () => { - expect(adapter.getComponent(bundle.XML_PATH)).to.deep.equal(bundle.COMPONENT); + expect(sharedAdapter.getComponent(bundle.XML_PATH)).to.deep.equal(bundle.COMPONENT); }); it('Should return expected SourceComponent when given a bundle directory', () => { - expect(adapter.getComponent(bundle.CONTENT_PATH)).to.deep.equal(bundle.COMPONENT); + expect(sharedAdapter.getComponent(bundle.CONTENT_PATH)).to.deep.equal(bundle.COMPONENT); }); it('Should exclude empty bundle directories', () => { @@ -46,7 +51,7 @@ describe('BundleSourceAdapter with AuraBundle', () => { it('Should return expected SourceComponent when given a source path', () => { const randomSource = bundle.SOURCE_PATHS[1]; - expect(adapter.getComponent(randomSource)).to.deep.equal(bundle.COMPONENT); + expect(sharedAdapter.getComponent(randomSource)).to.deep.equal(bundle.COMPONENT); }); describe('deeply nested LWC', () => { diff --git a/test/resolve/treeContainers.test.ts b/test/resolve/treeContainers.test.ts index dd8df33c47..cfb99c767d 100644 --- a/test/resolve/treeContainers.test.ts +++ b/test/resolve/treeContainers.test.ts @@ -88,68 +88,66 @@ describe('Tree Containers', () => { describe('NodeFSTreeContainer', () => { const env = createSandbox(); - const tree = new NodeFSTreeContainer(); - const path = join('path', 'to', 'test'); + const sharedTree = new NodeFSTreeContainer(); + const sharedPath = join('path', 'to', 'test'); afterEach(() => env.restore()); it('should use expected Node API for exists', () => { const existsStub = env.stub(fs, 'existsSync'); - existsStub.withArgs(path).returns(true); - expect(tree.exists(path)).to.be.true; + existsStub.withArgs(sharedPath).returns(true); + expect(sharedTree.exists(sharedPath)).to.be.true; expect(existsStub.calledOnce).to.be.true; }); it('should use expected Node API for isDirectory', () => { const statStub = env.stub(fs, 'statSync'); - // @ts-ignore lstat returns more than isDirectory function - statStub.withArgs(path).returns({ isDirectory: () => true }); - expect(tree.isDirectory(path)).to.be.true; + // @ts-expect-error lstat returns more than isDirectory function + statStub.withArgs(sharedPath).returns({ isDirectory: () => true }); + expect(sharedTree.isDirectory(sharedPath)).to.be.true; expect(statStub.calledOnce).to.be.true; }); it('should use expected Node API for readDirectory', () => { const statStub = env.stub(fs, 'statSync'); - // @ts-ignore lstat returns more than isDirectory function - statStub.withArgs(path).returns({ isDirectory: () => true }); + // @ts-expect-error lstat returns more than isDirectory function + statStub.withArgs(sharedPath).returns({ isDirectory: () => true }); const readdirStub = env.stub(fs, 'readdirSync'); - // @ts-ignore wants Dirents but string[] works as well - readdirStub.withArgs(path).returns(readDirResults); - expect(tree.readDirectory(path)).to.deep.equal(readDirResults); + // @ts-expect-error wants Dirents but string[] works as well + readdirStub.withArgs(sharedPath).returns(readDirResults); + expect(sharedTree.readDirectory(sharedPath)).to.deep.equal(readDirResults); expect(readdirStub.calledOnce).to.be.true; }); it('should return empty array for readDirectory on a file path', () => { const statStub = env.stub(fs, 'statSync'); - // @ts-ignore lstat returns more than isDirectory function - statStub.withArgs(path).returns({ isDirectory: () => false }); - expect(tree.readDirectory(path)).to.deep.equal([]); + // @ts-expect-error lstat returns more than isDirectory function + statStub.withArgs(sharedPath).returns({ isDirectory: () => false }); + expect(sharedTree.readDirectory(sharedPath)).to.deep.equal([]); }); it('should use expected Node API for readFile', async () => { const readFileStub = env.stub(fs, 'readFileSync'); - // @ts-ignore wants Dirents but string[] works as well - readFileStub.withArgs(path).resolves(Buffer.from('test')); - const data = await tree.readFile(path); + readFileStub.withArgs(sharedPath).resolves(Buffer.from('test')); + const data = await sharedTree.readFile(sharedPath); expect(data.toString()).to.deep.equal('test'); expect(readFileStub.calledOnce).to.be.true; }); it('should use expected Node API for readFileSync', () => { const readFileStub = env.stub(fs, 'readFileSync'); - // @ts-ignore wants Dirents but string[] works as well - readFileStub.withArgs(path).returns(Buffer.from('test')); - const data = tree.readFileSync(path); + readFileStub.withArgs(sharedPath).returns(Buffer.from('test')); + const data = sharedTree.readFileSync(sharedPath); expect(data.toString()).to.deep.equal('test'); expect(readFileStub.calledOnce).to.be.true; }); it('should use expected Node API for stream', () => { const readable = new Readable(); - // @ts-ignore wants ReadStream but Readable works for testing + // @ts-expect-error wants ReadStream but Readable works for testing env.stub(fs, 'createReadStream').returns(readable); env.stub(fs, 'existsSync').returns(true); - expect(tree.stream(path)).to.deep.equal(readable); + expect(sharedTree.stream(sharedPath)).to.deep.equal(readable); }); describe('with projectPath/cwd', () => {