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:
Peter Sanderson
2026-02-12 14:59:12 +01:00
committed by Peter Sanderson
parent 74cec5c69f
commit ba6e673d2f
24 changed files with 1366 additions and 10 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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" }
]
}

View File

@@ -0,0 +1,5 @@
const baseConfig = require('../../jest.config.base');
module.exports = {
...baseConfig,
};

View 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"
}
}

View 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();
});
});

View File

@@ -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' }],
});
});
});

View 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.']);
});
});

View 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);
});
});
});

View 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 });
}
});
});

View 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,
};
};

View 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;
};

View 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>>;
};

View File

@@ -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');
});
});

View File

@@ -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);
},
};

View File

@@ -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,
];

View File

@@ -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([]);
});
});

View File

@@ -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);
},
};

View 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();

View 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;
};

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "libDev" },
"references": [{ "path": "../utils" }]
}

View File

@@ -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"