mirror of
https://github.com/trezor/trezor-suite.git
synced 2026-02-19 16:22:25 +01:00
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
This commit is contained in:
committed by
Peter Sanderson
parent
74cec5c69f
commit
ba6e673d2f
3
.github/workflows/check-code-validation.yml
vendored
3
.github/workflows/check-code-validation.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
5
packages/requirements/jest.config.cjs
Normal file
5
packages/requirements/jest.config.cjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const baseConfig = require('../../jest.config.base');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
};
|
||||
23
packages/requirements/package.json
Normal file
23
packages/requirements/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
69
packages/requirements/src/__tests__/execCliCommand.test.ts
Normal file
69
packages/requirements/src/__tests__/execCliCommand.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
86
packages/requirements/src/__tests__/report.test.ts
Normal file
86
packages/requirements/src/__tests__/report.test.ts
Normal file
@@ -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.']);
|
||||
});
|
||||
});
|
||||
238
packages/requirements/src/__tests__/runRequirements.test.ts
Normal file
238
packages/requirements/src/__tests__/runRequirements.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Requirement } from '../requirements/Requirement';
|
||||
import { runRequirements } from '../runRequirements';
|
||||
|
||||
const createRepoRequirement = (
|
||||
overrides: Partial<Requirement<'repo'>> & { name: string },
|
||||
): Requirement<'repo'> => ({
|
||||
scope: 'repo',
|
||||
verify: () => Promise.resolve([]),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createWorkspaceRequirement = (
|
||||
overrides: Partial<Requirement<'workspace'>> & { 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
82
packages/requirements/src/execCliCommand.ts
Normal file
82
packages/requirements/src/execCliCommand.ts
Normal file
@@ -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<string>;
|
||||
readonly options?: ExecOptions;
|
||||
};
|
||||
|
||||
export type ExecCliCommand = (params: ExecCliCommandParams) => Promise<ExecResult>;
|
||||
|
||||
export type ExecCliCommandDep = {
|
||||
readonly execCliCommand: ExecCliCommand;
|
||||
};
|
||||
|
||||
export type ExecCliCommandDeps = {
|
||||
readonly console: Pick<Console, 'log'>;
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
93
packages/requirements/src/getAffectedWorkspaces.ts
Normal file
93
packages/requirements/src/getAffectedWorkspaces.ts
Normal file
@@ -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<WorkspaceInfo>;
|
||||
};
|
||||
|
||||
export type GetAffectedWorkspaces = (cwd: string) => Promise<GetAffectedWorkspacesResult>;
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
47
packages/requirements/src/report.ts
Normal file
47
packages/requirements/src/report.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import type { RequirementResult } from './runRequirements';
|
||||
|
||||
export type ReportDeps = {
|
||||
readonly console: Pick<Console, 'log'>;
|
||||
};
|
||||
|
||||
export type Report = (results: ReadonlyArray<RequirementResult>) => 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;
|
||||
};
|
||||
30
packages/requirements/src/requirements/Requirement.ts
Normal file
30
packages/requirements/src/requirements/Requirement.ts
Normal file
@@ -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<T extends RequirementScope> = {
|
||||
readonly name: string;
|
||||
readonly scope: T;
|
||||
|
||||
readonly applies?: (context: WorkspaceContext) => boolean;
|
||||
|
||||
readonly verify: (
|
||||
context: T extends 'workspace' ? WorkspaceContext : RepoContext,
|
||||
) => Promise<ReadonlyArray<string>>;
|
||||
|
||||
readonly fix?: (
|
||||
context: T extends 'workspace' ? WorkspaceContext : RepoContext,
|
||||
) => Promise<ReadonlyArray<string>>;
|
||||
};
|
||||
@@ -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'),
|
||||
'<!-- - [Tests](skills/tests.md) – Test style guidelines and best practices -->',
|
||||
);
|
||||
|
||||
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'),
|
||||
['<!-- - [Tests](skills/tests.md)', 'This line remains inside the open comment'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
const errors = await requireAgentsSkills.verify(context);
|
||||
|
||||
expect(errors).toEqual(['AGENTS.md does not link to skills/tests.md']);
|
||||
});
|
||||
|
||||
it('ignores configured skill links', async () => {
|
||||
writeFileSync(
|
||||
join(repoRoot, 'skills', 'skills-and-code-style-contribution.md'),
|
||||
'# Skills',
|
||||
);
|
||||
writeFileSync(join(repoRoot, 'AGENTS.md'), '# Agents');
|
||||
|
||||
const errors = await requireAgentsSkills.verify(context);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('has repo scope', () => {
|
||||
expect(requireAgentsSkills.scope).toBe('repo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { Requirement } from '../Requirement';
|
||||
|
||||
const SKILLS_DIR = 'skills';
|
||||
const AGENTS_FILE = 'AGENTS.md';
|
||||
const IGNORED_SKILL_LINKS = new Set([
|
||||
'skills/skills-and-code-style-contribution.md', // not a skill
|
||||
'skills/dependency-injection.md', // only for some `packages`
|
||||
'skills/tests-native.md', // only for `native`
|
||||
]);
|
||||
|
||||
const stripComments = (content: string) => {
|
||||
let sanitized = '';
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < content.length) {
|
||||
const commentStartIndex = content.indexOf('<!--', cursor);
|
||||
|
||||
if (commentStartIndex === -1) {
|
||||
sanitized += content.slice(cursor);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
sanitized += content.slice(cursor, commentStartIndex);
|
||||
|
||||
const commentEndIndex = content.indexOf('-->', 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);
|
||||
},
|
||||
};
|
||||
@@ -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<Requirement<RequirementScope>> = [
|
||||
requireAgentsSkills,
|
||||
requirePackageJsonScripts,
|
||||
];
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
};
|
||||
|
||||
const REQUIRED_SCRIPTS: Record<string, RequiredScriptConfig> = {
|
||||
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<string, string | undefined>;
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
73
packages/requirements/src/run.ts
Normal file
73
packages/requirements/src/run.ts
Normal file
@@ -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<string>): 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 <value>` narrows affected workspaces by name substring.
|
||||
* Example: `--filter=@trezor/connect` runs workspace-scoped requirements only for
|
||||
* matching affected workspaces.
|
||||
*
|
||||
* - `--only <requirement-name>` 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();
|
||||
82
packages/requirements/src/runRequirements.ts
Normal file
82
packages/requirements/src/runRequirements.ts
Normal file
@@ -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<string>;
|
||||
};
|
||||
|
||||
type RunRequirementsProps = {
|
||||
readonly requirements: ReadonlyArray<Requirement<RequirementScope>>;
|
||||
readonly repoRoot: string;
|
||||
readonly workspaces?: ReadonlyArray<{ readonly dir: string; readonly name: string }>;
|
||||
readonly filter?: string;
|
||||
readonly mode: RequirementMode;
|
||||
};
|
||||
|
||||
const executeRequirement = <T extends RequirementScope>(
|
||||
requirement: Requirement<T>,
|
||||
context: T extends 'workspace' ? WorkspaceContext : RepoContext,
|
||||
mode: RequirementMode,
|
||||
): Promise<ReadonlyArray<string>> => {
|
||||
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<ReadonlyArray<RequirementResult>> => {
|
||||
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;
|
||||
};
|
||||
5
packages/requirements/tsconfig.json
Normal file
5
packages/requirements/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "libDev" },
|
||||
"references": [{ "path": "../utils" }]
|
||||
}
|
||||
14
yarn.lock
14
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"
|
||||
|
||||
Reference in New Issue
Block a user