From dd455ba6ff2bd35de389b75d7a3163f07c4b8456 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 9 Nov 2023 12:03:05 -0700 Subject: [PATCH] Add RepositoryFilesystem (#21) When linting a project, we need to constantly read the files within either the module template or the project. We want this tool to run as fast as possible, and to do this, we can cache the metadata and content of each file that we read so that we don't have to do it again. This class assists with that. --- Co-authored-by: Mark Stacey --- package.json | 1 + src/misc-utils.test.ts | 64 ++++++++ src/misc-utils.ts | 27 ++++ src/repository-filesystem.test.ts | 233 ++++++++++++++++++++++++++++++ src/repository-filesystem.ts | 69 +++++++++ tests/helpers.ts | 5 + tsconfig.json | 3 + yarn.lock | 170 +++++++++++++++++++++- 8 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 src/misc-utils.test.ts create mode 100644 src/misc-utils.ts create mode 100644 src/repository-filesystem.test.ts create mode 100644 src/repository-filesystem.ts diff --git a/package.json b/package.json index f462c5c..440e899 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@metamask/utils": "^8.2.0", "execa": "^5.1.1" }, "devDependencies": { diff --git a/src/misc-utils.test.ts b/src/misc-utils.test.ts new file mode 100644 index 0000000..d0d91de --- /dev/null +++ b/src/misc-utils.test.ts @@ -0,0 +1,64 @@ +import { writeFile } from '@metamask/utils/node'; +import fs from 'fs'; +import path from 'path'; + +import { getEntryStats } from './misc-utils'; +import { withinSandbox } from '../tests/helpers'; + +describe('getEntryStats', () => { + describe('given a file', () => { + it('returns the stats for the file', async () => { + expect.assertions(3); + + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent.file'); + await writeFile(filePath, ''); + + const stats = await getEntryStats(filePath); + + expect(stats).toHaveProperty('atime'); + expect(stats).toHaveProperty('ctime'); + expect(stats).toHaveProperty('mtime'); + }); + }); + + it('returns null if the file does not exist', async () => { + expect.assertions(1); + + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent.file'); + + expect(await getEntryStats(filePath)).toBeNull(); + }); + }); + + it('re-throws a wrapped version of any other error that occurs, assigning it the same code and giving it a stack', async () => { + expect.assertions(1); + + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent.file'); + await writeFile(filePath, ''); + try { + // Make sandbox root directory non-executable. + await fs.promises.chmod(sandbox.directoryPath, 0o600); + + await expect(getEntryStats(filePath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not get stats for file or directory '${filePath}'`, + code: 'EACCES', + stack: expect.any(String), + cause: expect.objectContaining({ + message: `EACCES: permission denied, stat '${filePath}'`, + code: 'EACCES', + }), + }), + ); + } finally { + // Make sandbox root directory executable again. + // Ideally, this should be handled by @metamask/utils. + await fs.promises.chmod(sandbox.directoryPath, 0o700); + } + }); + }); + }); +}); diff --git a/src/misc-utils.ts b/src/misc-utils.ts new file mode 100644 index 0000000..17ab3e9 --- /dev/null +++ b/src/misc-utils.ts @@ -0,0 +1,27 @@ +import { isErrorWithCode, wrapError } from '@metamask/utils/node'; +import type fs from 'fs'; +import * as fsPromises from 'fs/promises'; + +/** + * Retrieves information about the file or directory using `fs.stat`. + * + * @param entryPath - The path to the file or directory. + * @returns The stats for the file or directory if it exists, or null if it does + * not exist. + * @throws An error with a stack trace if reading fails in any way. + */ +export async function getEntryStats( + entryPath: string, +): Promise { + try { + return await fsPromises.stat(entryPath); + } catch (error) { + if (isErrorWithCode(error) && error.code === 'ENOENT') { + return null; + } + throw wrapError( + error, + `Could not get stats for file or directory '${entryPath}'`, + ); + } +} diff --git a/src/repository-filesystem.test.ts b/src/repository-filesystem.test.ts new file mode 100644 index 0000000..2f490ae --- /dev/null +++ b/src/repository-filesystem.test.ts @@ -0,0 +1,233 @@ +import * as utils from '@metamask/utils/node'; +import { + ensureDirectoryStructureExists, + writeFile, +} from '@metamask/utils/node'; +import fs from 'fs'; +import { mock } from 'jest-mock-extended'; +import path from 'path'; + +import { RepositoryFilesystem } from './repository-filesystem'; +import { withinSandbox } from '../tests/helpers'; + +jest.mock('@metamask/utils/node', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + ...jest.requireActual('@metamask/utils/node'), + }; +}); + +const utilsMock = jest.mocked(utils); + +describe('RepositoryFilesystem', () => { + describe('readFile', () => { + describe('if the file has not already been read', () => { + it('reads the file from the repository directory', async () => { + jest.spyOn(utilsMock, 'readFile').mockResolvedValue('some content'); + const repositoryFilesystem = new RepositoryFilesystem( + '/some/directory', + ); + + await repositoryFilesystem.readFile('some.file'); + + expect(utilsMock.readFile).toHaveBeenCalledWith( + '/some/directory/some.file', + ); + }); + + it('returns the content of the file, with extra whitespace trimmed', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + await writeFile( + path.join(sandboxDirectoryPath, 'some.file'), + ' some content ', + ); + const repositoryFilesystem = new RepositoryFilesystem( + sandboxDirectoryPath, + ); + + const content = await repositoryFilesystem.readFile('some.file'); + + expect(content).toBe('some content'); + }); + }); + }); + + describe('if the file has already been read', () => { + it('does not read the file from the repository directory again', async () => { + jest.spyOn(utilsMock, 'readFile').mockResolvedValue('some content'); + const repositoryFilesystem = new RepositoryFilesystem( + '/some/directory', + ); + await repositoryFilesystem.readFile('/some/file'); + + await repositoryFilesystem.readFile('/some/file'); + + expect(utilsMock.readFile).toHaveBeenCalledTimes(1); + }); + + it('returns the content of the file, with extra whitespace trimmed', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + await writeFile( + path.join(sandboxDirectoryPath, 'some.file'), + ' some content ', + ); + const repositoryFilesystem = new RepositoryFilesystem( + sandboxDirectoryPath, + ); + await repositoryFilesystem.readFile('some.file'); + + const content = await repositoryFilesystem.readFile('some.file'); + + expect(content).toBe('some content'); + }); + }); + }); + }); + + describe('getEntryStats', () => { + describe('given a file', () => { + describe('if stats have not been requested for the file already', () => { + it('requests the stats for the file', async () => { + jest.spyOn(fs.promises, 'stat').mockResolvedValue(mock()); + const repositoryFilesystem = new RepositoryFilesystem( + '/some/directory', + ); + + await repositoryFilesystem.getEntryStats('some-entry'); + + expect(fs.promises.stat).toHaveBeenCalledWith( + '/some/directory/some-entry', + ); + }); + + it('returns stats for the file', async () => { + await withinSandbox( + async ({ directoryPath: sandboxDirectoryPath }) => { + await writeFile(path.join(sandboxDirectoryPath, 'some.file'), ''); + const repositoryFilesystem = new RepositoryFilesystem( + sandboxDirectoryPath, + ); + + const stats = await repositoryFilesystem.getEntryStats( + 'some.file', + ); + + expect(stats).toHaveProperty('atime'); + expect(stats).toHaveProperty('ctime'); + expect(stats).toHaveProperty('mtime'); + }, + ); + }); + }); + + describe('if stats have been requested for the file already', () => { + it('does not request the stats for the file again', async () => { + jest.spyOn(fs.promises, 'stat').mockResolvedValue(mock()); + const repositoryFilesystem = new RepositoryFilesystem( + '/some/directory', + ); + await repositoryFilesystem.getEntryStats('some-entry'); + + await repositoryFilesystem.getEntryStats('some-entry'); + + expect(fs.promises.stat).toHaveBeenCalledTimes(1); + }); + + it('returns stats for the file', async () => { + await withinSandbox( + async ({ directoryPath: sandboxDirectoryPath }) => { + await writeFile(path.join(sandboxDirectoryPath, 'some.file'), ''); + const repositoryFilesystem = new RepositoryFilesystem( + sandboxDirectoryPath, + ); + await repositoryFilesystem.getEntryStats('some.file'); + + const stats = await repositoryFilesystem.getEntryStats( + 'some.file', + ); + + expect(stats).toHaveProperty('atime'); + expect(stats).toHaveProperty('ctime'); + expect(stats).toHaveProperty('mtime'); + }, + ); + }); + }); + }); + + describe('given a directory', () => { + describe('if stats have not been requested for the directory already', () => { + it('requests the stats for the directory', async () => { + jest.spyOn(fs.promises, 'stat').mockResolvedValue(mock()); + const repositoryFilesystem = new RepositoryFilesystem( + '/some/directory', + ); + + await repositoryFilesystem.getEntryStats('/another/directory'); + + expect(fs.promises.stat).toHaveBeenCalledWith( + '/some/directory/another/directory', + ); + }); + + it('returns stats for the directory', async () => { + await withinSandbox( + async ({ directoryPath: sandboxDirectoryPath }) => { + await ensureDirectoryStructureExists( + path.join(sandboxDirectoryPath, 'some-directory'), + ); + const repositoryFilesystem = new RepositoryFilesystem( + sandboxDirectoryPath, + ); + + const stats = await repositoryFilesystem.getEntryStats( + 'some-directory', + ); + + expect(stats).toHaveProperty('atime'); + expect(stats).toHaveProperty('ctime'); + expect(stats).toHaveProperty('mtime'); + }, + ); + }); + }); + + describe('if stats have been requested for the directory already', () => { + it('does not request the stats for the directory again', async () => { + jest.spyOn(fs.promises, 'stat').mockResolvedValue(mock()); + const repositoryFilesystem = new RepositoryFilesystem( + '/some/directory', + ); + await repositoryFilesystem.getEntryStats('another-directory'); + + await repositoryFilesystem.getEntryStats('another-directory'); + + expect(fs.promises.stat).toHaveBeenCalledTimes(1); + }); + + it('returns stats for the directory', async () => { + await withinSandbox( + async ({ directoryPath: sandboxDirectoryPath }) => { + await ensureDirectoryStructureExists( + path.join(sandboxDirectoryPath, 'some-directory'), + ); + const repositoryFilesystem = new RepositoryFilesystem( + sandboxDirectoryPath, + ); + await repositoryFilesystem.getEntryStats('some-directory'); + + const stats = await repositoryFilesystem.getEntryStats( + 'some-directory', + ); + + expect(stats).toHaveProperty('atime'); + expect(stats).toHaveProperty('ctime'); + expect(stats).toHaveProperty('mtime'); + }, + ); + }); + }); + }); + }); +}); diff --git a/src/repository-filesystem.ts b/src/repository-filesystem.ts new file mode 100644 index 0000000..b46d390 --- /dev/null +++ b/src/repository-filesystem.ts @@ -0,0 +1,69 @@ +import { readFile } from '@metamask/utils/node'; +import type fs from 'fs'; +import path from 'path'; + +import { getEntryStats } from './misc-utils'; + +/** + * Used to access files within either a project (the repository being linted) or + * a template (the repository that the project is compared to). Any filesystem + * operation performed is cached to make rule execution for the same project or + * template as fast as possible. + */ +export class RepositoryFilesystem { + #directoryPath: string; + + #fileContents: Record; + + #entryStats: Record; + + /** + * Constructs a RepositoryFilesystem. + * + * @param directoryPath - The path to the repository. + */ + constructor(directoryPath: string) { + this.#directoryPath = directoryPath; + this.#fileContents = {}; + this.#entryStats = {}; + } + + /** + * Reads a file within the repository. + * + * @param filePath - The path to the file relative to the repository root. + * @returns The contents of the file. + */ + async readFile(filePath: string): Promise { + const cachedContent = this.#fileContents[filePath]; + const content = + cachedContent ?? (await readFile(this.#getFullPath(filePath))).trim(); + this.#fileContents[filePath] = content; + return content; + } + + /** + * Retrieves stats for the given file or directory. + * + * @param entryPath - The path to the file relative to the repository root. + * @returns The `fs.Stats` object with information about the entry. + */ + async getEntryStats(entryPath: string): Promise { + const cachedStats = this.#entryStats[entryPath]; + const stats = + cachedStats ?? (await getEntryStats(this.#getFullPath(entryPath))); + this.#entryStats[entryPath] = stats; + return stats; + } + + /** + * Builds the full path to a file or directory within the repository from a + * path relative to the root directory of the repository. + * + * @param entryPath - The path to the file relative to the repository root. + * @returns The full path. + */ + #getFullPath(entryPath: string): string { + return path.join(this.#directoryPath, entryPath); + } +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 783a09c..6b61c48 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,3 +1,4 @@ +import { createSandbox } from '@metamask/utils/node'; import type { ExecaChildProcess, Options as ExecaOptions, @@ -6,6 +7,10 @@ import type { import { mock } from 'jest-mock-extended'; import { inspect, isDeepStrictEqual } from 'util'; +const { withinSandbox } = createSandbox('module-lint-tests'); + +export { withinSandbox }; + /** * `execa` can be called multiple ways. This is the way that we use it. */ diff --git a/tsconfig.json b/tsconfig.json index 23c8ceb..eb5ceb7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,9 @@ "noEmit": true, "noErrorTruncation": true, "noUncheckedIndexedAccess": true, + "paths": { + "@metamask/utils/node": ["./node_modules/@metamask/utils/dist/types/node"] + }, "strict": true, "target": "es2020" }, diff --git a/yarn.lock b/yarn.lock index 9010cf9..3632816 100644 --- a/yarn.lock +++ b/yarn.lock @@ -482,6 +482,48 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/common@npm:^3.2.0": + version: 3.2.0 + resolution: "@ethereumjs/common@npm:3.2.0" + dependencies: + "@ethereumjs/util": ^8.1.0 + crc-32: ^1.2.0 + checksum: cb9cc11f5c868cb577ba611cebf55046e509218bbb89b47ccce010776dafe8256d70f8f43fab238aec74cf71f62601cd5842bc03a83261200802de365732a14b + languageName: node + linkType: hard + +"@ethereumjs/rlp@npm:^4.0.1": + version: 4.0.1 + resolution: "@ethereumjs/rlp@npm:4.0.1" + bin: + rlp: bin/rlp + checksum: 30db19c78faa2b6ff27275ab767646929207bb207f903f09eb3e4c273ce2738b45f3c82169ddacd67468b4f063d8d96035f2bf36f02b6b7e4d928eefe2e3ecbc + languageName: node + linkType: hard + +"@ethereumjs/tx@npm:^4.2.0": + version: 4.2.0 + resolution: "@ethereumjs/tx@npm:4.2.0" + dependencies: + "@ethereumjs/common": ^3.2.0 + "@ethereumjs/rlp": ^4.0.1 + "@ethereumjs/util": ^8.1.0 + ethereum-cryptography: ^2.0.0 + checksum: 87a3f5f2452cfbf6712f8847525a80c213210ed453c211c793c5df801fe35ecef28bae17fadd222fcbdd94277478a47e52d2b916a90a6b30cda21f1e0cdaee42 + languageName: node + linkType: hard + +"@ethereumjs/util@npm:^8.1.0": + version: 8.1.0 + resolution: "@ethereumjs/util@npm:8.1.0" + dependencies: + "@ethereumjs/rlp": ^4.0.1 + ethereum-cryptography: ^2.0.0 + micro-ftch: ^0.3.1 + checksum: 9ae5dee8f12b0faf81cd83f06a41560e79b0ba96a48262771d897a510ecae605eb6d84f687da001ab8ccffd50f612ae50f988ef76e6312c752897f462f3ac08d + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -954,6 +996,7 @@ __metadata: "@metamask/eslint-config-jest": ^12.0.0 "@metamask/eslint-config-nodejs": ^12.0.0 "@metamask/eslint-config-typescript": ^12.0.0 + "@metamask/utils": ^8.2.0 "@swc/cli": ^0.1.62 "@swc/core": ^1.3.66 "@types/jest": ^28.1.6 @@ -983,6 +1026,22 @@ __metadata: languageName: unknown linkType: soft +"@metamask/utils@npm:^8.2.0": + version: 8.2.0 + resolution: "@metamask/utils@npm:8.2.0" + dependencies: + "@ethereumjs/tx": ^4.2.0 + "@noble/hashes": ^1.3.1 + "@scure/base": ^1.1.3 + "@types/debug": ^4.1.7 + debug: ^4.3.4 + pony-cause: ^2.1.10 + semver: ^7.5.4 + superstruct: ^1.0.3 + checksum: 1c70c0f9c375bfa3836c15d48990dbea1c3cadfd3dd69b4867667116c09c3bdeef70a0c7027f1cdea88a9913cb846dc94812ece91be7ec32e65a62e00281b04c + languageName: node + linkType: hard + "@mole-inc/bin-wrapper@npm:^8.0.1": version: 8.0.1 resolution: "@mole-inc/bin-wrapper@npm:8.0.1" @@ -999,6 +1058,29 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:1.1.0, @noble/curves@npm:~1.1.0": + version: 1.1.0 + resolution: "@noble/curves@npm:1.1.0" + dependencies: + "@noble/hashes": 1.3.1 + checksum: 2658cdd3f84f71079b4e3516c47559d22cf4b55c23ac8ee9d2b1f8e5b72916d9689e59820e0f9d9cb4a46a8423af5b56dc6bb7782405c88be06a015180508db5 + languageName: node + linkType: hard + +"@noble/hashes@npm:1.3.1": + version: 1.3.1 + resolution: "@noble/hashes@npm:1.3.1" + checksum: 7fdefc0f7a0c1ec27acc6ff88841793e3f93ec4ce6b8a6a12bfc0dd70ae6b7c4c82fe305fdfeda1735d5ad4a9eebe761e6693b3d355689c559e91242f4bc95b1 + languageName: node + linkType: hard + +"@noble/hashes@npm:^1.3.1, @noble/hashes@npm:~1.3.0, @noble/hashes@npm:~1.3.1": + version: 1.3.2 + resolution: "@noble/hashes@npm:1.3.2" + checksum: fe23536b436539d13f90e4b9be843cc63b1b17666a07634a2b1259dded6f490be3d050249e6af98076ea8f2ea0d56f578773c2197f2aa0eeaa5fba5bc18ba474 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1075,6 +1157,34 @@ __metadata: languageName: node linkType: hard +"@scure/base@npm:^1.1.3, @scure/base@npm:~1.1.0": + version: 1.1.3 + resolution: "@scure/base@npm:1.1.3" + checksum: 1606ab8a4db898cb3a1ada16c15437c3bce4e25854fadc8eb03ae93cbbbac1ed90655af4b0be3da37e12056fef11c0374499f69b9e658c9e5b7b3e06353c630c + languageName: node + linkType: hard + +"@scure/bip32@npm:1.3.1": + version: 1.3.1 + resolution: "@scure/bip32@npm:1.3.1" + dependencies: + "@noble/curves": ~1.1.0 + "@noble/hashes": ~1.3.1 + "@scure/base": ~1.1.0 + checksum: 394d65f77a40651eba21a5096da0f4233c3b50d422864751d373fcf142eeedb94a1149f9ab1dbb078086dab2d0bc27e2b1afec8321bf22d4403c7df2fea5bfe2 + languageName: node + linkType: hard + +"@scure/bip39@npm:1.2.1": + version: 1.2.1 + resolution: "@scure/bip39@npm:1.2.1" + dependencies: + "@noble/hashes": ~1.3.0 + "@scure/base": ~1.1.0 + checksum: c5bd6f1328fdbeae2dcdd891825b1610225310e5e62a4942714db51066866e4f7bef242c7b06a1b9dcc8043a4a13412cf5c5df76d3b10aa9e36b82e9b6e3eeaa + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.24.1": version: 0.24.28 resolution: "@sinclair/typebox@npm:0.24.28" @@ -1356,6 +1466,15 @@ __metadata: languageName: node linkType: hard +"@types/debug@npm:^4.1.7": + version: 4.1.10 + resolution: "@types/debug@npm:4.1.10" + dependencies: + "@types/ms": "*" + checksum: 938f79c5b610f851da9c67ecd8641a09b33ce9cb38fe4c9f4d20ee743d6bccb5d8e9a833a4cd23e0684a316622af67a0634fa706baea5a01f5219961d1976314 + languageName: node + linkType: hard + "@types/glob@npm:^7.1.1": version: 7.1.3 resolution: "@types/glob@npm:7.1.3" @@ -1447,6 +1566,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 0.7.33 + resolution: "@types/ms@npm:0.7.33" + checksum: 2cb5af611ace05ab2ae40422c8539850cf983197982bb04b83acf59e6e692e2faccf336a82ac4db97f7ea28f2baa0a8990fa5eb1cd72c5cab17b5b3609b0b650 + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:^16": version: 16.18.31 resolution: "@types/node@npm:16.18.31" @@ -2502,6 +2628,15 @@ __metadata: languageName: node linkType: hard +"crc-32@npm:^1.2.0": + version: 1.2.2 + resolution: "crc-32@npm:1.2.2" + bin: + crc32: bin/crc32.njs + checksum: ad2d0ad0cbd465b75dcaeeff0600f8195b686816ab5f3ba4c6e052a07f728c3e70df2e3ca9fd3d4484dc4ba70586e161ca5a2334ec8bf5a41bf022a6103ff243 + languageName: node + linkType: hard + "create-require@npm:^1.1.0": version: 1.1.1 resolution: "create-require@npm:1.1.1" @@ -3210,6 +3345,18 @@ __metadata: languageName: node linkType: hard +"ethereum-cryptography@npm:^2.0.0": + version: 2.1.2 + resolution: "ethereum-cryptography@npm:2.1.2" + dependencies: + "@noble/curves": 1.1.0 + "@noble/hashes": 1.3.1 + "@scure/bip32": 1.3.1 + "@scure/bip39": 1.2.1 + checksum: 2e8f7b8cc90232ae838ab6a8167708e8362621404d26e79b5d9e762c7b53d699f7520aff358d9254de658fcd54d2d0af168ff909943259ed27dc4cef2736410c + languageName: node + linkType: hard + "execa@npm:^0.7.0": version: 0.7.0 resolution: "execa@npm:0.7.0" @@ -5059,6 +5206,13 @@ __metadata: languageName: node linkType: hard +"micro-ftch@npm:^0.3.1": + version: 0.3.1 + resolution: "micro-ftch@npm:0.3.1" + checksum: 0e496547253a36e98a83fb00c628c53c3fb540fa5aaeaf718438873785afd193244988c09d219bb1802984ff227d04938d9571ef90fe82b48bd282262586aaff + languageName: node + linkType: hard + "micromatch@npm:^4.0.4": version: 4.0.4 resolution: "micromatch@npm:4.0.4" @@ -5652,6 +5806,13 @@ __metadata: languageName: node linkType: hard +"pony-cause@npm:^2.1.10": + version: 2.1.10 + resolution: "pony-cause@npm:2.1.10" + checksum: 8b61378f213e61056312dc274a1c79980154e9d864f6ad86e0c8b91a50d3ce900d430995ee24147c9f3caa440dfe7d51c274b488d7f033b65b206522536d7217 + languageName: node + linkType: hard + "postcss@npm:^8.1.10": version: 8.4.31 resolution: "postcss@npm:8.4.31" @@ -6065,7 +6226,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.0.0, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8": +"semver@npm:7.x, semver@npm:^7.0.0, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.4": version: 7.5.4 resolution: "semver@npm:7.5.4" dependencies: @@ -6454,6 +6615,13 @@ __metadata: languageName: node linkType: hard +"superstruct@npm:^1.0.3": + version: 1.0.3 + resolution: "superstruct@npm:1.0.3" + checksum: 761790bb111e6e21ddd608299c252f3be35df543263a7ebbc004e840d01fcf8046794c274bcb351bdf3eae4600f79d317d085cdbb19ca05803a4361840cc9bb1 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0"