From ba6e673d2f8b36d7a82f1731c8853d89ed5cfc0a Mon Sep 17 00:00:00 2001 From: Peter Sanderson Date: Thu, 12 Feb 2026 14:59:12 +0100 Subject: [PATCH] feat: introduce a freamework to enforece some repository rules, via custom scripts feat: implement requirement that checks the AGENTS.md to verify it has all the skills linked fix: add it to CI demonstrate failure fix: AI adressing the comments fix: remove unnecessary parseArgs, use node native fix: bit of refactoring, introduce DI to test stuff fix: remove fix implmentation its too messy and I ddo not want to commit AI slop fix: di for report test with console feat: requirements uses nx-afected fix: refactor the requre affectd so it is not a slop fix: esm saddness fix: ignore pacakges fix: affected correcty test: intentionlly break it fix: CI for affected fix: revert, 'nrwl/nx-set-shas@v4' shall do the job fix: CI trial & error fix: ai slop to verify fix: comments stripping fix: try default fetch fix: strip fix: backfix the testing errors fix: missing components link fix: missing depcheck for analytics-docs fix: review fix: move tests fix: new ignored package local skill --- .github/workflows/check-code-validation.yml | 3 + AGENTS.md | 3 +- package.json | 2 + packages/analytics-docs/package.json | 7 +- packages/analytics-docs/tsconfig.json | 6 +- packages/requirements/jest.config.cjs | 5 + packages/requirements/package.json | 23 ++ .../src/__tests__/execCliCommand.test.ts | 69 +++++ .../__tests__/getAffectedWorkspaces.test.ts | 164 ++++++++++++ .../requirements/src/__tests__/report.test.ts | 86 +++++++ .../src/__tests__/runRequirements.test.ts | 238 ++++++++++++++++++ packages/requirements/src/execCliCommand.ts | 82 ++++++ .../requirements/src/getAffectedWorkspaces.ts | 93 +++++++ packages/requirements/src/report.ts | 47 ++++ .../src/requirements/Requirement.ts | 30 +++ .../__tests__/requireAgentsSkills.test.ts | 112 +++++++++ .../agents-skills/requireAgentsSkills.ts | 69 +++++ .../src/requirements/allRequirements.ts | 8 + .../requirePackageJsonScripts.test.ts | 93 +++++++ .../package-json/requirePackageJsonScripts.ts | 62 +++++ packages/requirements/src/run.ts | 73 ++++++ packages/requirements/src/runRequirements.ts | 82 ++++++ packages/requirements/tsconfig.json | 5 + yarn.lock | 14 +- 24 files changed, 1366 insertions(+), 10 deletions(-) create mode 100644 packages/requirements/jest.config.cjs create mode 100644 packages/requirements/package.json create mode 100644 packages/requirements/src/__tests__/execCliCommand.test.ts create mode 100644 packages/requirements/src/__tests__/getAffectedWorkspaces.test.ts create mode 100644 packages/requirements/src/__tests__/report.test.ts create mode 100644 packages/requirements/src/__tests__/runRequirements.test.ts create mode 100644 packages/requirements/src/execCliCommand.ts create mode 100644 packages/requirements/src/getAffectedWorkspaces.ts create mode 100644 packages/requirements/src/report.ts create mode 100644 packages/requirements/src/requirements/Requirement.ts create mode 100644 packages/requirements/src/requirements/agents-skills/__tests__/requireAgentsSkills.test.ts create mode 100644 packages/requirements/src/requirements/agents-skills/requireAgentsSkills.ts create mode 100644 packages/requirements/src/requirements/allRequirements.ts create mode 100644 packages/requirements/src/requirements/package-json/__tests__/requirePackageJsonScripts.test.ts create mode 100644 packages/requirements/src/requirements/package-json/requirePackageJsonScripts.ts create mode 100644 packages/requirements/src/run.ts create mode 100644 packages/requirements/src/runRequirements.ts create mode 100644 packages/requirements/tsconfig.json diff --git a/.github/workflows/check-code-validation.yml b/.github/workflows/check-code-validation.yml index b80a221d0e..30fb80174b 100644 --- a/.github/workflows/check-code-validation.yml +++ b/.github/workflows/check-code-validation.yml @@ -156,6 +156,8 @@ jobs: uses: ./.github/actions/nx-checkout - name: "Minimal yarn install" uses: ./.github/actions/minimal-yarn-install + - name: Verify Requirements + run: yarn requirements:verify - name: Check Files for Correct Formatting run: yarn nx format:check - name: Verify TS Project References @@ -173,6 +175,7 @@ jobs: - name: Translation Duplicates run: yarn workspace @suite/intl translations:list-duplicates + releases-revision-checks: name: Releases revision Checks needs: setup-and-cache diff --git a/AGENTS.md b/AGENTS.md index 8f162ee285..916bd630e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ - [Code Style Guide](skills/index.md) – How to contribute code style proposals - [Comments](skills/comments.md) – Comment formatting conventions - [Common Issues](skills/common-issues.md) – Known issues and their solutions -- [Common Tasks](skills/common-tasks.md) – Dependency management, package creation, and troubleshooting - [Components](skills/components.md) – React component file structure and patterns +- [Common Tasks](skills/common-tasks.md) – Dependency management, package creation, and troubleshooting - [Defensive Programming](skills/defensive-programming.md) – Exhaustive checks and safe defaults - [Development Commands](skills/development-commands.md) – Running apps, linting, testing, and building - [Git and Commit Guidelines](skills/git-and-commit-guidelines.md) – Conventional Commits format and best practices @@ -17,6 +17,7 @@ - [Project Overview](skills/project-structure.md) – What Trezor Suite is and how the monorepo is organized - [Redux](skills/redux.md) – Redux Toolkit patterns and best practices - [Setup Requirements](skills/setup-requirements.md) – Prerequisites and initial environment setup +- [Tests](skills/tests.md) – Test style guidelines and best practices - [Tests Commands](skills/tests-commands.md) – Running tests and test-related guidelines - [Tests](skills/tests.md) – Test style guidelines and best practices - [TypeScript](skills/typescript.md) – TypeScript-specific conventions diff --git a/package.json b/package.json index 780c95b8b7..23f3ece25e 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "update-project-references": "yarn tsx ./scripts/updateProjectReferences.ts", "verify-project-references": "yarn update-project-references --test", "check-workspace-resolutions": "yarn tsx ./scripts/check-workspace-resolutions.ts", + "requirements:verify": "yarn workspace @trezor/requirements requirements:verify", + "requirements:fix": "yarn workspace @trezor/requirements requirements:fix", "generate-icons": "yarn workspace @suite-common/icons generate-icons", "generate-package": "yarn workspace @trezor/scripts generate-package", "deps": "rimraf **/node_modules && yarn", diff --git a/packages/analytics-docs/package.json b/packages/analytics-docs/package.json index bd39a85069..347b99c73b 100644 --- a/packages/analytics-docs/package.json +++ b/packages/analytics-docs/package.json @@ -7,14 +7,15 @@ "dev": "[ -f public/analytics.json ] || yarn build-data; vite", "build": "yarn build-data && vite build", "preview": "vite preview", - "type-check": "yarn g:tsc --build tsconfig.json" + "type-check": "yarn g:tsc --build tsconfig.json", + "depcheck": "yarn g:depcheck" }, "dependencies": { "@suite-common/analytics": "workspace:*", - "@suite-native/analytics": "workspace:*", - "@suite/analytics": "workspace:*", "@trezor/components": "workspace:*", "@trezor/eslint": "workspace:*", + "@trezor/react-utils": "workspace:*", + "@trezor/theme": "workspace:*", "@trezor/utils": "workspace:*", "@vitejs/plugin-react": "^4.7.0", "react": "19.1.0", diff --git a/packages/analytics-docs/tsconfig.json b/packages/analytics-docs/tsconfig.json index a248672ec0..ef933c110c 100644 --- a/packages/analytics-docs/tsconfig.json +++ b/packages/analytics-docs/tsconfig.json @@ -14,12 +14,10 @@ { "path": "../../suite-common/analytics" }, - { - "path": "../../suite-native/analytics" - }, - { "path": "../../suite/analytics" }, { "path": "../components" }, { "path": "../eslint" }, + { "path": "../react-utils" }, + { "path": "../theme" }, { "path": "../utils" } ] } diff --git a/packages/requirements/jest.config.cjs b/packages/requirements/jest.config.cjs new file mode 100644 index 0000000000..5a88e23868 --- /dev/null +++ b/packages/requirements/jest.config.cjs @@ -0,0 +1,5 @@ +const baseConfig = require('../../jest.config.base'); + +module.exports = { + ...baseConfig, +}; diff --git a/packages/requirements/package.json b/packages/requirements/package.json new file mode 100644 index 0000000000..450ee5ee89 --- /dev/null +++ b/packages/requirements/package.json @@ -0,0 +1,23 @@ +{ + "name": "@trezor/requirements", + "version": "1.0.0", + "main": "src/index", + "license": "See LICENSE.md in repo root", + "private": true, + "sideEffects": false, + "scripts": { + "depcheck": "yarn g:depcheck", + "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'", + "type-check": "yarn g:tsc --build", + "test:unit": "yarn g:jest", + "requirements:verify": "tsx src/run.ts verify", + "requirements:fix": "tsx src/run.ts fix" + }, + "dependencies": { + "@trezor/utils": "workspace:*", + "chalk": "^5.6.2" + }, + "devDependencies": { + "tsx": "^4.21.0" + } +} diff --git a/packages/requirements/src/__tests__/execCliCommand.test.ts b/packages/requirements/src/__tests__/execCliCommand.test.ts new file mode 100644 index 0000000000..07780e6805 --- /dev/null +++ b/packages/requirements/src/__tests__/execCliCommand.test.ts @@ -0,0 +1,69 @@ +import { createExecCliCommand } from '../execCliCommand'; + +const execCliCommand = createExecCliCommand({ + console: { + log: jest.fn(), + }, +}); + +describe(execCliCommand.name, () => { + it('captures stdout from a simple command', async () => { + const result = await execCliCommand({ command: 'echo', args: ['hello world'] }); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('hello world'); + expect(result.stderr).toBe(''); + }); + + it('returns non-zero exit code on failure', async () => { + const result = await execCliCommand({ command: 'sh', args: ['-c', 'exit 42'] }); + + expect(result.exitCode).toBe(42); + }); + + it('captures stderr', async () => { + const result = await execCliCommand({ + command: 'sh', + args: ['-c', 'echo oops >&2; exit 1'], + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr.trim()).toBe('oops'); + }); + + it('respects cwd option', async () => { + const result = await execCliCommand({ command: 'pwd', args: [], options: { cwd: '/tmp' } }); + + expect(result.exitCode).toBe(0); + // /tmp may resolve to a real path on some systems + expect(result.stdout.trim()).toMatch(/\/tmp/); + }); + + it('times out long-running processes', async () => { + const result = await execCliCommand({ + command: 'sleep', + args: ['10'], + options: { timeout: 100 }, + }); + + expect(result.exitCode).toBe(124); + expect(result.stderr).toContain('timed out'); + }); + + it('does not timeout when process finishes within timeout', async () => { + const result = await execCliCommand({ + command: 'sleep', + args: ['1'], + options: { timeout: 3000 }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + }); + + it('rejects when command does not exist', async () => { + await expect( + execCliCommand({ command: 'nonexistent-command-abc123', args: [] }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/requirements/src/__tests__/getAffectedWorkspaces.test.ts b/packages/requirements/src/__tests__/getAffectedWorkspaces.test.ts new file mode 100644 index 0000000000..7e3dbac1ff --- /dev/null +++ b/packages/requirements/src/__tests__/getAffectedWorkspaces.test.ts @@ -0,0 +1,164 @@ +import { type ExecResult } from '../execCliCommand'; +import { createGetAffectedWorkspaces } from '../getAffectedWorkspaces'; + +type CreateTestGetAffectedWorkspacesOptions = { + readonly workspaceListResult: ExecResult; + readonly affectedResult: ExecResult; +}; + +const createTestGetAffectedWorkspaces = (options: CreateTestGetAffectedWorkspacesOptions) => + createGetAffectedWorkspaces({ + execCliCommand: jest + .fn() + .mockResolvedValueOnce(options.workspaceListResult) + .mockResolvedValueOnce(options.affectedResult), + }); + +describe(createGetAffectedWorkspaces.name, () => { + afterEach(() => { + delete process.env.NX_BASE; + delete process.env.NX_HEAD; + }); + + it('returns only affected workspaces', async () => { + const getAffectedWorkspaces = createTestGetAffectedWorkspaces({ + workspaceListResult: { + exitCode: 0, + stderr: '', + stdout: [ + '{"name":"trezor-suite","location":"."}', + '{"name":"@trezor/connect","location":"packages/connect"}', + '{"name":"@trezor/suite","location":"packages/suite"}', + '{"name":"@trezor/requirements","location":"packages/requirements"}', + ].join('\n'), + }, + affectedResult: { + exitCode: 0, + stderr: '', + stdout: '["@trezor/connect","@trezor/suite"]', + }, + }); + + const result = await getAffectedWorkspaces('/repo/packages/requirements'); + + expect(result).toEqual({ + repoRoot: '/repo', + workspaces: [ + { name: '@trezor/connect', dir: '/repo/packages/connect' }, + { name: '@trezor/suite', dir: '/repo/packages/suite' }, + ], + }); + }); + + it('returns empty workspace list when nx output is empty', async () => { + const getAffectedWorkspaces = createTestGetAffectedWorkspaces({ + workspaceListResult: { + exitCode: 0, + stderr: '', + stdout: [ + '{"name":"trezor-suite","location":"."}', + '{"name":"@trezor/connect","location":"packages/connect"}', + ].join('\n'), + }, + affectedResult: { + exitCode: 0, + stderr: '', + stdout: '\n', + }, + }); + + const result = await getAffectedWorkspaces('/repo/packages/requirements'); + + expect(result).toEqual({ + repoRoot: '/repo', + workspaces: [], + }); + }); + + it('throws when nx command fails', async () => { + const getAffectedWorkspaces = createTestGetAffectedWorkspaces({ + workspaceListResult: { + exitCode: 0, + stderr: '', + stdout: [ + '{"name":"trezor-suite","location":"."}', + '{"name":"@trezor/connect","location":"packages/connect"}', + ].join('\n'), + }, + affectedResult: { + exitCode: 1, + stdout: '', + stderr: 'fatal: bad revision', + }, + }); + + await expect(getAffectedWorkspaces('/repo/packages/requirements')).rejects.toThrow( + 'Failed to determine affected projects: fatal: bad revision', + ); + }); + + it('throws when nx output is not an array of project names', async () => { + const getAffectedWorkspaces = createTestGetAffectedWorkspaces({ + workspaceListResult: { + exitCode: 0, + stderr: '', + stdout: [ + '{"name":"trezor-suite","location":"."}', + '{"name":"@trezor/connect","location":"packages/connect"}', + ].join('\n'), + }, + affectedResult: { + exitCode: 0, + stdout: '{"projects":["@trezor/connect"]}', + stderr: '', + }, + }); + + await expect(getAffectedWorkspaces('/repo/packages/requirements')).rejects.toThrow( + 'Failed to determine affected projects: invalid Nx output format.', + ); + }); + + it('throws when listing workspaces fails', async () => { + const getAffectedWorkspaces = createGetAffectedWorkspaces({ + execCliCommand: jest.fn(() => + Promise.resolve({ + exitCode: 1, + stdout: '', + stderr: 'boom', + }), + ), + }); + + await expect(getAffectedWorkspaces('/repo/packages/requirements')).rejects.toThrow( + 'Failed to list workspaces: boom', + ); + }); + + it('ignores empty lines in workspace list output', async () => { + const getAffectedWorkspaces = createTestGetAffectedWorkspaces({ + workspaceListResult: { + exitCode: 0, + stderr: '', + stdout: [ + '{"name":"trezor-suite","location":"."}', + '', + '{"name":"@trezor/connect","location":"packages/connect"}', + '', + ].join('\n'), + }, + affectedResult: { + exitCode: 0, + stderr: '', + stdout: '["@trezor/connect"]', + }, + }); + + const result = await getAffectedWorkspaces('/repo/packages/requirements'); + + expect(result).toEqual({ + repoRoot: '/repo', + workspaces: [{ name: '@trezor/connect', dir: '/repo/packages/connect' }], + }); + }); +}); diff --git a/packages/requirements/src/__tests__/report.test.ts b/packages/requirements/src/__tests__/report.test.ts new file mode 100644 index 0000000000..efdf2cb077 --- /dev/null +++ b/packages/requirements/src/__tests__/report.test.ts @@ -0,0 +1,86 @@ +import { stripVTControlCharacters } from 'node:util'; + +import { ReportDeps, createReport } from '../report'; +import type { RequirementResult } from '../runRequirements'; + +const createConsoleMock = (journal: string[]): ReportDeps['console'] => ({ + log: (message: string) => { + journal.push(message); + }, +}); + +describe('report', () => { + it('returns 0 when all requirements pass', () => { + const data: string[] = []; + const report = createReport({ console: createConsoleMock(data) }); + + const results: RequirementResult[] = [ + { requirement: 'check-a', target: 'repo', errors: [] }, + { requirement: 'check-b', target: 'repo', errors: [] }, + ]; + + const exitCode = report(results); + const normalizedData = data.map(stripVTControlCharacters); + + expect(exitCode).toBe(0); + expect(normalizedData).toEqual([ + ' ✓ check-a [repo]', + ' ✓ check-b [repo]', + '', + 'All 2 requirement(s) passed.', + ]); + }); + + it('returns 1 when any requirement fails', () => { + const data: string[] = []; + const report = createReport({ console: createConsoleMock(data) }); + + const results: RequirementResult[] = [ + { requirement: 'check-a', target: 'repo', errors: ['broken'] }, + ]; + + const exitCode = report(results); + const normalizedData = data.map(stripVTControlCharacters); + + expect(exitCode).toBe(1); + expect(normalizedData).toEqual([ + ' ✗ check-a [repo]', + ' broken', + '', + '1 error(s) in 1 requirement(s) failed.', + ]); + }); + + it('reports both passing and failing requirements', () => { + const data: string[] = []; + const report = createReport({ console: createConsoleMock(data) }); + + const results: RequirementResult[] = [ + { requirement: 'good', target: 'repo', errors: [] }, + { requirement: 'bad', target: 'alpha', errors: ['error-1', 'error-2'] }, + ]; + + const exitCode = report(results); + const normalizedData = data.map(stripVTControlCharacters); + + expect(exitCode).toBe(1); + expect(normalizedData).toEqual([ + ' ✓ good [repo]', + ' ✗ bad [alpha]', + ' error-1', + ' error-2', + '', + '2 error(s) in 1 requirement(s) failed.', + ]); + }); + + it('returns 0 for empty results', () => { + const data: string[] = []; + const report = createReport({ console: createConsoleMock(data) }); + const exitCode = report([]); + const normalizedData = data.map(stripVTControlCharacters); + + expect(exitCode).toBe(0); + expect(normalizedData).toEqual(['', 'All 0 requirement(s) passed.']); + }); +}); diff --git a/packages/requirements/src/__tests__/runRequirements.test.ts b/packages/requirements/src/__tests__/runRequirements.test.ts new file mode 100644 index 0000000000..eed7fc0faf --- /dev/null +++ b/packages/requirements/src/__tests__/runRequirements.test.ts @@ -0,0 +1,238 @@ +import type { Requirement } from '../requirements/Requirement'; +import { runRequirements } from '../runRequirements'; + +const createRepoRequirement = ( + overrides: Partial> & { name: string }, +): Requirement<'repo'> => ({ + scope: 'repo', + verify: () => Promise.resolve([]), + ...overrides, +}); + +const createWorkspaceRequirement = ( + overrides: Partial> & { name: string }, +): Requirement<'workspace'> => ({ + scope: 'workspace', + verify: () => Promise.resolve([]), + ...overrides, +}); + +const workspaces = [ + { dir: '/repo/packages/alpha', name: '@trezor/alpha' }, + { dir: '/repo/packages/beta', name: '@trezor/beta' }, +]; + +describe('runRequirements', () => { + describe('repo-scoped requirements', () => { + it('returns no errors when verify passes', async () => { + const results = await runRequirements({ + requirements: [createRepoRequirement({ name: 'passing' })], + repoRoot: '/repo', + mode: 'verify', + }); + + expect(results).toHaveLength(1); + expect(results[0].errors).toEqual([]); + expect(results[0].target).toBe('repo'); + }); + + it('returns errors from verify', async () => { + const results = await runRequirements({ + requirements: [ + createRepoRequirement({ + name: 'failing', + verify: () => Promise.resolve(['something is wrong']), + }), + ], + repoRoot: '/repo', + mode: 'verify', + }); + + expect(results).toHaveLength(1); + expect(results[0].errors).toEqual(['something is wrong']); + expect(results[0].requirement).toBe('failing'); + }); + + it('calls fix when mode is fix and fix is defined', async () => { + const results = await runRequirements({ + requirements: [ + createRepoRequirement({ + name: 'fixable', + verify: () => Promise.resolve(['broken']), + fix: () => Promise.resolve([]), + }), + ], + repoRoot: '/repo', + mode: 'fix', + }); + + expect(results).toHaveLength(1); + expect(results[0].errors).toEqual([]); + }); + + it('falls back to verify when mode is fix but fix is undefined', async () => { + const results = await runRequirements({ + requirements: [ + createRepoRequirement({ + name: 'no-fix', + verify: () => Promise.resolve(['still broken']), + }), + ], + repoRoot: '/repo', + mode: 'fix', + }); + + expect(results).toHaveLength(1); + expect(results[0].errors).toEqual(['still broken']); + }); + }); + + describe('workspace-scoped requirements', () => { + it('runs against each workspace', async () => { + const visited: string[] = []; + + await runRequirements({ + requirements: [ + createWorkspaceRequirement({ + name: 'tracker', + verify: ctx => { + visited.push(ctx.workspaceName); + + return Promise.resolve([]); + }, + }), + ], + repoRoot: '/repo', + workspaces, + mode: 'verify', + }); + + expect(visited).toEqual(['@trezor/alpha', '@trezor/beta']); + }); + + it('skips workspaces where applies returns false', async () => { + const results = await runRequirements({ + requirements: [ + createWorkspaceRequirement({ + name: 'selective', + applies: ctx => ctx.workspaceName === '@trezor/alpha', + verify: () => Promise.resolve(['error']), + }), + ], + repoRoot: '/repo', + workspaces, + mode: 'verify', + }); + + // Only alpha should have the error; beta was skipped + expect(results).toHaveLength(1); + expect(results[0].target).toBe('alpha'); + }); + + it('collects errors across multiple workspaces', async () => { + const results = await runRequirements({ + requirements: [ + createWorkspaceRequirement({ + name: 'broken-everywhere', + verify: ctx => Promise.resolve([`${ctx.workspaceName} is broken`]), + }), + ], + repoRoot: '/repo', + workspaces, + mode: 'verify', + }); + + expect(results).toHaveLength(2); + expect(results[0].errors).toEqual(['@trezor/alpha is broken']); + expect(results[1].errors).toEqual(['@trezor/beta is broken']); + }); + + it('includes workspace results even with no errors', async () => { + const results = await runRequirements({ + requirements: [ + createWorkspaceRequirement({ + name: 'partial-fail', + verify: ctx => + Promise.resolve( + ctx.workspaceName === '@trezor/alpha' ? ['broken'] : [], + ), + }), + ], + repoRoot: '/repo', + workspaces, + mode: 'verify', + }); + + expect(results).toHaveLength(2); + expect(results[0].target).toBe('alpha'); + expect(results[0].errors).toEqual(['broken']); + expect(results[1].target).toBe('beta'); + expect(results[1].errors).toEqual([]); + }); + }); + + describe('filtering', () => { + it('runs only the requirement matching the filter', async () => { + const results = await runRequirements({ + requirements: [ + createRepoRequirement({ + name: 'req-a', + verify: () => Promise.resolve(['a-error']), + }), + createRepoRequirement({ + name: 'req-b', + verify: () => Promise.resolve(['b-error']), + }), + ], + repoRoot: '/repo', + filter: 'req-a', + mode: 'verify', + }); + + expect(results).toHaveLength(1); + expect(results[0].requirement).toBe('req-a'); + }); + + it('returns empty results when filter matches nothing', async () => { + const results = await runRequirements({ + requirements: [ + createRepoRequirement({ + name: 'req-a', + verify: () => Promise.resolve(['error']), + }), + ], + repoRoot: '/repo', + filter: 'nonexistent', + mode: 'verify', + }); + + expect(results).toEqual([]); + }); + }); + + describe('mixed scopes', () => { + it('handles repo and workspace requirements together', async () => { + const results = await runRequirements({ + requirements: [ + createRepoRequirement({ + name: 'repo-check', + verify: () => Promise.resolve(['repo-error']), + }), + createWorkspaceRequirement({ + name: 'ws-check', + verify: () => Promise.resolve(['ws-error']), + }), + ], + repoRoot: '/repo', + workspaces, + mode: 'verify', + }); + + const repoResults = results.filter(r => r.target === 'repo'); + const wsResults = results.filter(r => r.target !== 'repo'); + + expect(repoResults).toHaveLength(1); + expect(wsResults).toHaveLength(2); + }); + }); +}); diff --git a/packages/requirements/src/execCliCommand.ts b/packages/requirements/src/execCliCommand.ts new file mode 100644 index 0000000000..4e756a9b16 --- /dev/null +++ b/packages/requirements/src/execCliCommand.ts @@ -0,0 +1,82 @@ +import { spawn } from 'node:child_process'; + +export type ExecResult = { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +}; + +export type ExecOptions = { + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly timeout?: number; +}; + +export type ExecCliCommandParams = { + readonly command: string; + readonly args: ReadonlyArray; + readonly options?: ExecOptions; +}; + +export type ExecCliCommand = (params: ExecCliCommandParams) => Promise; + +export type ExecCliCommandDep = { + readonly execCliCommand: ExecCliCommand; +}; + +export type ExecCliCommandDeps = { + readonly console: Pick; +}; + +export const createExecCliCommand = + (deps: ExecCliCommandDeps): ExecCliCommand => + ({ command, args, options }) => + new Promise((resolve, reject) => { + const cwd = options?.cwd ?? process.cwd(); + deps.console.log(`[requirements] exec: ${command} ${args.join(' ')} (cwd: ${cwd})`); + + const proc = spawn(command, [...args], { + cwd: options?.cwd, + env: options?.env ?? process.env, + shell: false, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + proc.stdout.on('data', (chunk: Buffer) => { + stdoutChunks.push(chunk); + }); + + proc.stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + }); + + let timedOut = false; + + const timer = + options?.timeout !== undefined + ? setTimeout(() => { + timedOut = true; + proc.kill('SIGTERM'); + }, options.timeout) + : undefined; + + proc.on('error', error => { + if (timer !== undefined) clearTimeout(timer); + reject(error); + }); + + proc.on('close', exitCode => { + if (timer !== undefined) clearTimeout(timer); + + const stdout = Buffer.concat(stdoutChunks).toString('utf-8'); + const stderr = Buffer.concat(stderrChunks).toString('utf-8'); + + if (timedOut) { + resolve({ stdout, stderr: `${stderr}\nProcess timed out`, exitCode: 124 }); + } else { + resolve({ stdout, stderr, exitCode: exitCode ?? 1 }); + } + }); + }); diff --git a/packages/requirements/src/getAffectedWorkspaces.ts b/packages/requirements/src/getAffectedWorkspaces.ts new file mode 100644 index 0000000000..7d6ed091a2 --- /dev/null +++ b/packages/requirements/src/getAffectedWorkspaces.ts @@ -0,0 +1,93 @@ +import { join, resolve } from 'node:path'; + +import { ExecCliCommandDep } from './execCliCommand'; + +const ROOT_WORKSPACE_NAME = 'trezor-suite'; +const REPO_ROOT_FROM_REQUIREMENTS_WORKSPACE = '../..'; + +type WorkspaceInfo = { + readonly name: string; + readonly dir: string; +}; + +type YarnWorkspaceInfo = { + readonly name: string; + readonly location: string; +}; + +type GetAffectedWorkspacesResult = { + readonly repoRoot: string; + readonly workspaces: ReadonlyArray; +}; + +export type GetAffectedWorkspaces = (cwd: string) => Promise; + +export type GetAffectedWorkspacesDeps = ExecCliCommandDep; + +export const createGetAffectedWorkspaces = + (deps: GetAffectedWorkspacesDeps): GetAffectedWorkspaces => + async cwd => { + const workspacesResult = await deps.execCliCommand({ + command: 'yarn', + args: ['workspaces', 'list', '--json'], + options: { cwd }, + }); + + if (workspacesResult.exitCode !== 0) { + throw new Error(`Failed to list workspaces: ${workspacesResult.stderr}`); + } + + const parsedWorkspaces = workspacesResult.stdout + .trim() + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line) as YarnWorkspaceInfo); + + const repoRoot = resolve(cwd, REPO_ROOT_FROM_REQUIREMENTS_WORKSPACE); + + const allWorkspaces = parsedWorkspaces + .filter(workspace => workspace.name !== ROOT_WORKSPACE_NAME) + .map(workspace => ({ + name: workspace.name, + dir: join(repoRoot, workspace.location), + })); + + const nxAffectedResult = await deps.execCliCommand({ + command: 'yarn', + args: ['nx', 'show', 'projects', '--affected', '--json'], + options: { + cwd: repoRoot, + }, + }); + + if (nxAffectedResult.exitCode !== 0) { + throw new Error(`Failed to determine affected projects: ${nxAffectedResult.stderr}`); + } + + const trimmedOutput = nxAffectedResult.stdout.trim(); + + if (trimmedOutput.length === 0) { + return { + repoRoot, + workspaces: [], + }; + } + + const affectedWorkspaceNames = JSON.parse(trimmedOutput); + + if ( + !Array.isArray(affectedWorkspaceNames) || + !affectedWorkspaceNames.every(project => typeof project === 'string') + ) { + throw new Error('Failed to determine affected projects: invalid Nx output format.'); + } + + const workspaces = allWorkspaces.filter(workspace => + affectedWorkspaceNames.includes(workspace.name), + ); + + return { + repoRoot, + workspaces, + }; + }; diff --git a/packages/requirements/src/report.ts b/packages/requirements/src/report.ts new file mode 100644 index 0000000000..dca4db19f6 --- /dev/null +++ b/packages/requirements/src/report.ts @@ -0,0 +1,47 @@ +import chalk from 'chalk'; + +import type { RequirementResult } from './runRequirements'; + +export type ReportDeps = { + readonly console: Pick; +}; + +export type Report = (results: ReadonlyArray) => number; + +export type ReportDep = { + readonly report: Report; +}; + +export const createReport = + (deps: ReportDeps): Report => + results => { + const failed = results.filter(result => result.errors.length > 0); + const passed = results.filter(result => result.errors.length === 0); + + for (const result of passed) { + deps.console.log(chalk.green(` ✓ ${result.requirement} [${result.target}]`)); + } + + for (const result of failed) { + deps.console.log(chalk.red(` ✗ ${result.requirement} [${result.target}]`)); + + for (const error of result.errors) { + deps.console.log(chalk.red(` ${error}`)); + } + } + + deps.console.log(''); + + if (failed.length > 0) { + const errorCount = failed.reduce((sum, result) => sum + result.errors.length, 0); + deps.console.log( + chalk.red(`${errorCount} error(s) in ${failed.length} requirement(s) failed.`), + ); + + return 1; + } + + deps.console.log(chalk.green(`All ${passed.length} requirement(s) passed.`)); + + return 0; + }; diff --git a/packages/requirements/src/requirements/Requirement.ts b/packages/requirements/src/requirements/Requirement.ts new file mode 100644 index 0000000000..1400fe6821 --- /dev/null +++ b/packages/requirements/src/requirements/Requirement.ts @@ -0,0 +1,30 @@ +export type RequirementScope = 'repo' | 'workspace'; + +export type RequirementMode = 'verify' | 'fix'; + +export type RepoContext = { + readonly repoRoot: string; +}; + +export type WorkspaceContext = { + readonly repoRoot: string; + readonly workspaceDir: string; + readonly workspaceName: string; +}; + +export type RequirementContext = RepoContext | WorkspaceContext; + +export type Requirement = { + readonly name: string; + readonly scope: T; + + readonly applies?: (context: WorkspaceContext) => boolean; + + readonly verify: ( + context: T extends 'workspace' ? WorkspaceContext : RepoContext, + ) => Promise>; + + readonly fix?: ( + context: T extends 'workspace' ? WorkspaceContext : RepoContext, + ) => Promise>; +}; diff --git a/packages/requirements/src/requirements/agents-skills/__tests__/requireAgentsSkills.test.ts b/packages/requirements/src/requirements/agents-skills/__tests__/requireAgentsSkills.test.ts new file mode 100644 index 0000000000..584729d6b9 --- /dev/null +++ b/packages/requirements/src/requirements/agents-skills/__tests__/requireAgentsSkills.test.ts @@ -0,0 +1,112 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { RepoContext } from '../../Requirement'; +import { requireAgentsSkills } from '../requireAgentsSkills'; + +const createTempRepo = (): string => mkdtempSync(join(tmpdir(), 'agents-skills-')); + +describe(requireAgentsSkills.name, () => { + let repoRoot: string; + let context: RepoContext; + + beforeEach(() => { + repoRoot = createTempRepo(); + context = { repoRoot }; + mkdirSync(join(repoRoot, 'skills')); + }); + + afterEach(() => { + rmSync(repoRoot, { recursive: true, force: true }); + }); + + it('passes when all skill files are linked', async () => { + writeFileSync(join(repoRoot, 'skills', 'testing.md'), '# Testing'); + writeFileSync(join(repoRoot, 'skills', 'setup.md'), '# Setup'); + writeFileSync( + join(repoRoot, 'AGENTS.md'), + ['## Skills', '- [Testing](skills/testing.md)', '- [Setup](skills/setup.md)'].join( + '\n', + ), + ); + + const errors = await requireAgentsSkills.verify(context); + + expect(errors).toEqual([]); + }); + + it('reports unlinked skill files', async () => { + writeFileSync(join(repoRoot, 'skills', 'testing.md'), '# Testing'); + writeFileSync(join(repoRoot, 'skills', 'missing.md'), '# Missing'); + writeFileSync(join(repoRoot, 'AGENTS.md'), '- [Testing](skills/testing.md)'); + + const errors = await requireAgentsSkills.verify(context); + + expect(errors).toEqual(['AGENTS.md does not link to skills/missing.md']); + }); + + it('reports multiple unlinked files', async () => { + writeFileSync(join(repoRoot, 'skills', 'a.md'), ''); + writeFileSync(join(repoRoot, 'skills', 'b.md'), ''); + writeFileSync(join(repoRoot, 'AGENTS.md'), '# Agents'); + + const errors = await requireAgentsSkills.verify(context); + + expect(errors).toHaveLength(2); + expect(errors).toContain('AGENTS.md does not link to skills/a.md'); + expect(errors).toContain('AGENTS.md does not link to skills/b.md'); + }); + + it('ignores non-markdown files in skills directory', async () => { + writeFileSync(join(repoRoot, 'skills', 'notes.txt'), ''); + writeFileSync(join(repoRoot, 'skills', 'script.sh'), ''); + writeFileSync(join(repoRoot, 'AGENTS.md'), '# Agents'); + + const errors = await requireAgentsSkills.verify(context); + + expect(errors).toEqual([]); + }); + + it('does not count links inside HTML comments', async () => { + writeFileSync(join(repoRoot, 'skills', 'tests.md'), '# Tests'); + writeFileSync( + join(repoRoot, 'AGENTS.md'), + '', + ); + + const errors = await requireAgentsSkills.verify(context); + + expect(errors).toEqual(['AGENTS.md does not link to skills/tests.md']); + }); + + it('does not count links inside unterminated HTML comments', async () => { + writeFileSync(join(repoRoot, 'skills', 'tests.md'), '# Tests'); + writeFileSync( + join(repoRoot, 'AGENTS.md'), + ['', commentStartIndex + 4); + + if (commentEndIndex === -1) { + break; + } + + cursor = commentEndIndex + 3; + } + + return sanitized; +}; + +/** + * Verifies that every markdown file in skills is linked from AGENTS.md + */ +export const requireAgentsSkills: Requirement<'repo'> = { + name: 'agents-skills-linked', + scope: 'repo', + verify: ({ repoRoot }) => { + const errors: string[] = []; + const skillsPath = join(repoRoot, SKILLS_DIR); + const agentsPath = join(repoRoot, AGENTS_FILE); + + const skillFiles = readdirSync(skillsPath).filter(f => f.endsWith('.md')); + const agentsContent = stripComments(readFileSync(agentsPath, 'utf-8')); + + for (const file of skillFiles) { + const link = `${SKILLS_DIR}/${file}`; + + if (IGNORED_SKILL_LINKS.has(link)) { + continue; + } + + if (!agentsContent.includes(link)) { + errors.push(`${AGENTS_FILE} does not link to ${link}`); + } + } + + return Promise.resolve(errors); + }, +}; diff --git a/packages/requirements/src/requirements/allRequirements.ts b/packages/requirements/src/requirements/allRequirements.ts new file mode 100644 index 0000000000..f33d3efd1b --- /dev/null +++ b/packages/requirements/src/requirements/allRequirements.ts @@ -0,0 +1,8 @@ +import type { Requirement, RequirementScope } from './Requirement'; +import { requireAgentsSkills } from './agents-skills/requireAgentsSkills'; +import { requirePackageJsonScripts } from './package-json/requirePackageJsonScripts'; + +export const requirements: ReadonlyArray> = [ + requireAgentsSkills, + requirePackageJsonScripts, +]; diff --git a/packages/requirements/src/requirements/package-json/__tests__/requirePackageJsonScripts.test.ts b/packages/requirements/src/requirements/package-json/__tests__/requirePackageJsonScripts.test.ts new file mode 100644 index 0000000000..3ac71ac001 --- /dev/null +++ b/packages/requirements/src/requirements/package-json/__tests__/requirePackageJsonScripts.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { WorkspaceContext } from '../../Requirement'; +import { requirePackageJsonScripts } from '../requirePackageJsonScripts'; + +const createTempWorkspace = (): string => mkdtempSync(join(tmpdir(), 'package-json-')); + +describe(requirePackageJsonScripts.name, () => { + let workspaceDir: string; + let context: WorkspaceContext; + + beforeEach(() => { + workspaceDir = createTempWorkspace(); + context = { + repoRoot: '/repo', + workspaceDir, + workspaceName: '@trezor/example', + }; + }); + + afterEach(() => { + rmSync(workspaceDir, { recursive: true, force: true }); + }); + + it('passes when depcheck script is configured correctly', async () => { + writeFileSync( + join(workspaceDir, 'package.json'), + JSON.stringify({ scripts: { depcheck: 'yarn g:depcheck' } }), + ); + + const errors = await requirePackageJsonScripts.verify(context); + + expect(errors).toEqual([]); + }); + + it('reports missing depcheck script', async () => { + writeFileSync(join(workspaceDir, 'package.json'), JSON.stringify({ scripts: {} })); + + const errors = await requirePackageJsonScripts.verify(context); + + expect(errors).toEqual([ + '@trezor/example: scripts.depcheck must be "yarn g:depcheck" in package.json.', + ]); + }); + + it('reports invalid depcheck value', async () => { + writeFileSync( + join(workspaceDir, 'package.json'), + JSON.stringify({ scripts: { depcheck: 'depcheck' } }), + ); + + const errors = await requirePackageJsonScripts.verify(context); + + expect(errors).toEqual([ + '@trezor/example: scripts.depcheck must be "yarn g:depcheck" in package.json.', + ]); + }); + + it('reports missing or invalid package.json', async () => { + const errorsWithoutFile = await requirePackageJsonScripts.verify(context); + + expect(errorsWithoutFile).toEqual([ + '@trezor/example: package.json is missing or contains invalid JSON.', + ]); + + writeFileSync(join(workspaceDir, 'package.json'), '{ invalid json }'); + + const errorsInvalidJson = await requirePackageJsonScripts.verify(context); + + expect(errorsInvalidJson).toEqual([ + '@trezor/example: package.json is missing or contains invalid JSON.', + ]); + }); + + it('has workspace scope', () => { + expect(requirePackageJsonScripts.scope).toBe('workspace'); + }); + + it('ignores depcheck requirement for configured packages', async () => { + context = { + ...context, + workspaceName: 'connect-example-node', + }; + + writeFileSync(join(workspaceDir, 'package.json'), JSON.stringify({ scripts: {} })); + + const errors = await requirePackageJsonScripts.verify(context); + + expect(errors).toEqual([]); + }); +}); diff --git a/packages/requirements/src/requirements/package-json/requirePackageJsonScripts.ts b/packages/requirements/src/requirements/package-json/requirePackageJsonScripts.ts new file mode 100644 index 0000000000..6d374201da --- /dev/null +++ b/packages/requirements/src/requirements/package-json/requirePackageJsonScripts.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { typedObjectEntries } from '@trezor/utils'; + +import type { Requirement } from '../Requirement'; + +const PACKAGE_JSON_FILE = 'package.json'; + +type RequiredScriptConfig = { + readonly command: string; + readonly ignoredPackages?: ReadonlyArray; +}; + +const REQUIRED_SCRIPTS: Record = { + depcheck: { + command: 'yarn g:depcheck', + ignoredPackages: [ + 'connect-example-electron-main', + 'connect-mobile-example', + 'connect-example-node', + '@trezor/webextension-mv3-sw-ts', + ], + }, +}; + +type PackageJson = { + readonly scripts?: Record; +}; + +export const requirePackageJsonScripts: Requirement<'workspace'> = { + name: 'package-json-scripts', + scope: 'workspace', + verify: context => { + const packageJsonPath = join(context.workspaceDir, PACKAGE_JSON_FILE); + + let parsed: PackageJson; + + try { + parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as PackageJson; + } catch { + return Promise.resolve([ + `${context.workspaceName}: ${PACKAGE_JSON_FILE} is missing or contains invalid JSON.`, + ]); + } + + const errors = typedObjectEntries(REQUIRED_SCRIPTS) + .filter(([scriptName, scriptConfig]) => { + if (scriptConfig.ignoredPackages?.includes(context.workspaceName)) { + return false; + } + + return parsed.scripts?.[scriptName] !== scriptConfig.command; + }) + .map( + ([scriptName, scriptConfig]) => + `${context.workspaceName}: scripts.${scriptName} must be "${scriptConfig.command}" in ${PACKAGE_JSON_FILE}.`, + ); + + return Promise.resolve(errors); + }, +}; diff --git a/packages/requirements/src/run.ts b/packages/requirements/src/run.ts new file mode 100644 index 0000000000..4325607c26 --- /dev/null +++ b/packages/requirements/src/run.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env tsx + +import { parseArgs } from 'node:util'; + +import { createExecCliCommand } from './execCliCommand'; +import { createGetAffectedWorkspaces } from './getAffectedWorkspaces'; +import { createReport } from './report'; +import type { RequirementMode } from './requirements/Requirement'; +import { requirements } from './requirements/allRequirements'; +import { runRequirements } from './runRequirements'; + +const getModeFromPositionals = (positionals: ReadonlyArray): RequirementMode => { + const mode = positionals[0]; + + if (mode === 'verify' || mode === 'fix') { + return mode; + } + + throw new Error(`Invalid mode "${mode ?? ''}". Use "verify" or "fix".`); +}; + +/** + * Runs requirements in selected mode (`verify` or `fix`) for Nx-affected workspaces. + * + * CLI options: + * - `--filter ` narrows affected workspaces by name substring. + * Example: `--filter=@trezor/connect` runs workspace-scoped requirements only for + * matching affected workspaces. + * + * - `--only ` runs only a single requirement by exact requirement + * name (for example `agents-skills-linked` or `package-json`). + */ +const main = async () => { + // Composition Root + const execCliCommand = createExecCliCommand({ console }); + const getAffectedWorkspaces = createGetAffectedWorkspaces({ execCliCommand }); + const report = createReport({ console }); + + // Run + const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + filter: { type: 'string' }, + only: { type: 'string' }, + }, + strict: false, + allowPositionals: true, + }); + + const mode = getModeFromPositionals(positionals); + const filter = typeof values.filter === 'string' ? values.filter : undefined; + const only = typeof values.only === 'string' ? values.only : undefined; + + const { repoRoot, workspaces: affectedWorkspaces } = await getAffectedWorkspaces(process.cwd()); + + const workspaces = + filter !== undefined + ? affectedWorkspaces.filter(workspace => workspace.name.includes(filter)) + : affectedWorkspaces; + + const results = await runRequirements({ + requirements, + repoRoot, + workspaces, + filter: only, + mode, + }); + + const exitCode = report(results); + process.exit(exitCode); +}; + +main(); diff --git a/packages/requirements/src/runRequirements.ts b/packages/requirements/src/runRequirements.ts new file mode 100644 index 0000000000..10241d3c14 --- /dev/null +++ b/packages/requirements/src/runRequirements.ts @@ -0,0 +1,82 @@ +import { basename } from 'node:path'; + +import type { + RepoContext, + Requirement, + RequirementMode, + RequirementScope, + WorkspaceContext, +} from './requirements/Requirement'; + +export type RequirementResult = { + readonly requirement: string; + readonly target: string; + readonly errors: ReadonlyArray; +}; + +type RunRequirementsProps = { + readonly requirements: ReadonlyArray>; + readonly repoRoot: string; + readonly workspaces?: ReadonlyArray<{ readonly dir: string; readonly name: string }>; + readonly filter?: string; + readonly mode: RequirementMode; +}; + +const executeRequirement = ( + requirement: Requirement, + context: T extends 'workspace' ? WorkspaceContext : RepoContext, + mode: RequirementMode, +): Promise> => { + if (mode === 'fix' && requirement.fix !== undefined) { + return requirement.fix(context); + } + + return requirement.verify(context); +}; + +export const runRequirements = async ({ + requirements, + repoRoot, + workspaces = [], + filter, + mode, +}: RunRequirementsProps): Promise> => { + const filtered = + filter !== undefined ? requirements.filter(r => r.name === filter) : requirements; + + const results: RequirementResult[] = []; + + for (const requirement of filtered) { + if (requirement.scope === 'repo') { + const errors = await executeRequirement(requirement, { repoRoot }, mode); + + results.push({ + requirement: requirement.name, + target: 'repo', + errors, + }); + } else { + for (const workspace of workspaces) { + const context: WorkspaceContext = { + repoRoot, + workspaceDir: workspace.dir, + workspaceName: workspace.name, + }; + + if (requirement.applies !== undefined && !requirement.applies(context)) { + continue; + } + + const errors = await executeRequirement(requirement, context, mode); + + results.push({ + requirement: requirement.name, + target: basename(workspace.dir), + errors, + }); + } + } + } + + return results; +}; diff --git a/packages/requirements/tsconfig.json b/packages/requirements/tsconfig.json new file mode 100644 index 0000000000..0ec4519c33 --- /dev/null +++ b/packages/requirements/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "libDev" }, + "references": [{ "path": "../utils" }] +} diff --git a/yarn.lock b/yarn.lock index 6d885c974b..2545ac288c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14813,10 +14813,10 @@ __metadata: resolution: "@trezor/analytics-docs@workspace:packages/analytics-docs" dependencies: "@suite-common/analytics": "workspace:*" - "@suite-native/analytics": "workspace:*" - "@suite/analytics": "workspace:*" "@trezor/components": "workspace:*" "@trezor/eslint": "workspace:*" + "@trezor/react-utils": "workspace:*" + "@trezor/theme": "workspace:*" "@trezor/utils": "workspace:*" "@vitejs/plugin-react": "npm:^4.7.0" react: "npm:19.1.0" @@ -15516,6 +15516,16 @@ __metadata: languageName: unknown linkType: soft +"@trezor/requirements@workspace:packages/requirements": + version: 0.0.0-use.local + resolution: "@trezor/requirements@workspace:packages/requirements" + dependencies: + "@trezor/utils": "workspace:*" + chalk: "npm:^5.6.2" + tsx: "npm:^4.21.0" + languageName: unknown + linkType: soft + "@trezor/schema-utils@workspace:*, @trezor/schema-utils@workspace:^, @trezor/schema-utils@workspace:packages/schema-utils": version: 0.0.0-use.local resolution: "@trezor/schema-utils@workspace:packages/schema-utils"