diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fdccfe138..ccbfc3f11 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,8 @@ # Every Dependabot PR triggers the full pipeline (~15 jobs), so version -# updates are batched: weekly cadence, minor+patch bumps grouped into one PR -# per ecosystem, majors as individual PRs so CI gates them one by one. -# Security updates are separate and are not limited by this schedule. +# updates are batched weekly. Nx minor+patch updates use a dedicated group so +# official packages move together; other minor+patch updates are grouped per +# ecosystem, and majors remain individual. Security updates are separate and +# are not limited by this schedule; CI enforces complete Nx lockstep. version: 2 updates: - package-ecosystem: npm @@ -12,7 +13,24 @@ updates: time: '06:00' open-pull-requests-limit: 5 groups: + nx-version-updates: + applies-to: version-updates + patterns: + - nx + - '@nx/*' + update-types: + - minor + - patch + nx-security-updates: + applies-to: security-updates + patterns: + - nx + - '@nx/*' npm-minor-patch: + applies-to: version-updates + exclude-patterns: + - nx + - '@nx/*' update-types: - minor - patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af603b71a..5b72d2cba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Validate Nx dependency version policy + run: pnpm run deps:nx:validate + - name: Typecheck web and Electron entry points run: pnpm run typecheck:ci diff --git a/AGENTS.md b/AGENTS.md index 0829e2403..32f1cb395 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ This file provides guidance to coding agents working in this repository. - Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. +- Keep `nx` and every official `@nx/*` package on the same exact version; run + `pnpm run deps:nx:validate` after dependency updates. +- Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the + lockfile, run generated migrations when present, and validate before opening + a PR. Major updates are always manual. Replace incomplete Dependabot security + PRs with a coordinated update instead of editing the bot branch. - ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused. - Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`. - Repository-specific skills live under `.codex/skills/`. diff --git a/CLAUDE.md b/CLAUDE.md index 6f6c85c3d..a2d95db04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,12 @@ pnpm nx show projects - Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. +- Keep `nx` and every official `@nx/*` package on the same exact version; run + `pnpm run deps:nx:validate` after dependency updates. +- Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the + lockfile, run generated migrations when present, and validate before opening + a PR. Major updates are always manual. Replace incomplete Dependabot security + PRs with a coordinated update instead of editing the bot branch. - Repository-specific skills live under `.codex/skills/`. - Frontmatter descriptions are trigger-only and begin with `Use when`; keep each skill at or below 500 words. diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index e73a47b54..202ab7cee 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -27,6 +27,29 @@ Do not invent a `test`, `build`, or `e2e` target because a similarly named project has one. Run affected lint/test/build targets that exist and the closest available E2E target for the changed behavior. +## Nx Dependency Updates + +Keep `nx` and every official `@nx/*` package on the same exact version. Run +`pnpm run deps:nx:validate` after any manifest or lockfile update; CI runs the +same policy check and rejects both direct specifier drift and multiple resolved +Nx versions. + +Dependabot groups routine minor and patch Nx updates when possible. A security +update may still contain only the vulnerable package, so replace an incomplete +Dependabot PR with a coordinated maintainer update instead of editing the bot +branch: + +```bash +pnpm nx migrate nx@ --skipInstall +pnpm install --no-frozen-lockfile +pnpm nx migrate --run-migrations +pnpm run deps:nx:validate +``` + +Omit `pnpm nx migrate --run-migrations` when the first command reports that no +migrations exist. Major Nx updates always use this manual workflow and the +resulting PR runs the full CI pipeline. + ## Placement Decision - `apps/` owns runtime applications, development servers, E2E applications, diff --git a/package.json b/package.json index 3c6b8f80e..3d3247f17 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "test:backend": "nx test electron-backend", "test:unit:all": "nx run-many --target=test --all --parallel=3", "test:unit:ci": "nx run-many --target=test --all --parallel=3 --output-style=static", + "deps:nx:test": "node --test tools/dependencies/check-nx-version-sync.test.mjs", + "deps:nx:check": "node tools/dependencies/check-nx-version-sync.mjs", + "deps:nx:validate": "pnpm run deps:nx:test && pnpm run deps:nx:check", "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", diff --git a/tools/dependencies/check-nx-version-sync.mjs b/tools/dependencies/check-nx-version-sync.mjs new file mode 100644 index 000000000..8ae344c0c --- /dev/null +++ b/tools/dependencies/check-nx-version-sync.mjs @@ -0,0 +1,105 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parse } from 'yaml'; + +const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export function validateNxVersionSync({ packageJson, lockfile }) { + const diagnostics = []; + const expectedVersion = packageJson.devDependencies?.nx; + + if (!expectedVersion) { + diagnostics.push('package.json: devDependencies.nx is required'); + } else if (!EXACT_SEMVER.test(expectedVersion)) { + diagnostics.push( + `package.json: nx must use an exact semantic version, found "${expectedVersion}"` + ); + } + + if (!expectedVersion || !EXACT_SEMVER.test(expectedVersion)) { + return { diagnostics, expectedVersion }; + } + + for (const sectionName of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + ]) { + const section = packageJson[sectionName] ?? {}; + for (const [packageName, specifier] of Object.entries(section)) { + if ( + (packageName === 'nx' || packageName.startsWith('@nx/')) && + specifier !== expectedVersion + ) { + diagnostics.push( + `package.json: ${sectionName}["${packageName}"] must match nx@${expectedVersion}, found "${specifier}"` + ); + } + } + } + + let foundExpectedNx = false; + for (const packageKey of Object.keys(lockfile.packages ?? {}).sort()) { + const nxMatch = /^nx@(.+)$/.exec(packageKey); + if (nxMatch) { + const resolvedVersion = nxMatch[1]; + if (resolvedVersion === expectedVersion) { + foundExpectedNx = true; + } else { + diagnostics.push( + `pnpm-lock.yaml: resolved nx@${resolvedVersion} must match nx@${expectedVersion}` + ); + } + continue; + } + + const officialPackageMatch = /^(@nx\/[^@]+)@(.+)$/.exec(packageKey); + if ( + officialPackageMatch && + officialPackageMatch[2] !== expectedVersion + ) { + diagnostics.push( + `pnpm-lock.yaml: resolved ${officialPackageMatch[1]}@${officialPackageMatch[2]} must match nx@${expectedVersion}` + ); + } + } + + if (!foundExpectedNx) { + diagnostics.push( + `pnpm-lock.yaml: expected nx@${expectedVersion} to be resolved` + ); + } + + return { diagnostics, expectedVersion }; +} + +const isMain = + process.argv[1] && + resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + const rootDir = process.cwd(); + const packageJson = JSON.parse( + await readFile(resolve(rootDir, 'package.json'), 'utf8') + ); + const lockfile = parse( + await readFile(resolve(rootDir, 'pnpm-lock.yaml'), 'utf8') + ); + const { diagnostics, expectedVersion } = validateNxVersionSync({ + packageJson, + lockfile, + }); + + if (diagnostics.length > 0) { + console.error('Nx dependency version policy failed:'); + for (const diagnostic of diagnostics) console.error(`- ${diagnostic}`); + process.exitCode = 1; + } else { + console.log( + `Nx dependency versions are synchronized at ${expectedVersion}.` + ); + } +} diff --git a/tools/dependencies/check-nx-version-sync.test.mjs b/tools/dependencies/check-nx-version-sync.test.mjs new file mode 100644 index 000000000..de0f3ee92 --- /dev/null +++ b/tools/dependencies/check-nx-version-sync.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { stringify } from 'yaml'; + +const validatorPath = fileURLToPath( + new URL('./check-nx-version-sync.mjs', import.meta.url) +); + +const alignedPackageJson = { + devDependencies: { + '@nx/angular': '22.7.1', + nx: '22.7.1', + 'nx-electron': '22.0.0', + }, +}; + +const alignedPackages = { + '@nx/angular@22.7.1': {}, + 'nx@22.7.1': {}, + 'nx-electron@22.0.0': {}, +}; + +async function createFixture(t, { packageJson, packages }) { + const rootDir = await mkdtemp(join(tmpdir(), 'nx-version-sync-')); + t.after(() => rm(rootDir, { recursive: true, force: true })); + await writeFile( + join(rootDir, 'package.json'), + `${JSON.stringify(packageJson, null, 2)}\n` + ); + await writeFile( + join(rootDir, 'pnpm-lock.yaml'), + stringify({ lockfileVersion: '9.0', packages }) + ); + return rootDir; +} + +function runValidator(rootDir) { + return spawnSync(process.execPath, [validatorPath], { + cwd: rootDir, + encoding: 'utf8', + }); +} + +test('accepts aligned official Nx packages and ignores nx-electron', async (t) => { + const rootDir = await createFixture(t, { + packageJson: alignedPackageJson, + packages: alignedPackages, + }); + + const result = runValidator(rootDir); + + assert.equal(result.status, 0, result.stderr); + assert.match( + result.stdout, + /Nx dependency versions are synchronized at 22\.7\.1\./ + ); +}); + +test('rejects a direct official Nx package mismatch', async (t) => { + const rootDir = await createFixture(t, { + packageJson: { + devDependencies: { + '@nx/angular': '22.7.1', + nx: '22.7.2', + }, + }, + packages: { + '@nx/angular@22.7.1': {}, + 'nx@22.7.2': {}, + }, + }); + + const result = runValidator(rootDir); + + assert.equal(result.status, 1); + assert.match( + result.stderr, + /devDependencies\["@nx\/angular"\] must match nx@22\.7\.2, found "22\.7\.1"/ + ); +}); + +test('rejects a direct official Nx peer dependency mismatch', async (t) => { + const rootDir = await createFixture(t, { + packageJson: { + devDependencies: { + nx: '22.7.2', + }, + peerDependencies: { + '@nx/angular': '22.7.1', + }, + }, + packages: { + '@nx/angular@22.7.2': {}, + 'nx@22.7.2': {}, + }, + }); + + const result = runValidator(rootDir); + + assert.equal(result.status, 1); + assert.match( + result.stderr, + /peerDependencies\["@nx\/angular"\] must match nx@22\.7\.2, found "22\.7\.1"/ + ); +}); + +test('rejects an extra direct Nx declaration mismatch', async (t) => { + const rootDir = await createFixture(t, { + packageJson: { + devDependencies: { + nx: '22.7.2', + }, + optionalDependencies: { + nx: '22.7.1', + }, + }, + packages: { + 'nx@22.7.2': {}, + }, + }); + + const result = runValidator(rootDir); + + assert.equal(result.status, 1); + assert.match( + result.stderr, + /optionalDependencies\["nx"\] must match nx@22\.7\.2, found "22\.7\.1"/ + ); +}); + +test('rejects a non-exact root Nx version', async (t) => { + const rootDir = await createFixture(t, { + packageJson: { + devDependencies: { + '@nx/angular': '^22.7.1', + nx: '^22.7.1', + }, + }, + packages: alignedPackages, + }); + + const result = runValidator(rootDir); + + assert.equal(result.status, 1); + assert.match( + result.stderr, + /nx must use an exact semantic version, found "\^22\.7\.1"/ + ); +}); + +test('rejects an extra resolved Nx version', async (t) => { + const rootDir = await createFixture(t, { + packageJson: { + devDependencies: { + '@nx/angular': '22.7.2', + nx: '22.7.2', + }, + }, + packages: { + '@nx/angular@22.7.2': {}, + 'nx@22.7.1': {}, + 'nx@22.7.2': {}, + }, + }); + + const result = runValidator(rootDir); + + assert.equal(result.status, 1); + assert.match(result.stderr, /resolved nx@22\.7\.1 must match nx@22\.7\.2/); +}); + +test('rejects a transitive official Nx lockfile mismatch', async (t) => { + const rootDir = await createFixture(t, { + packageJson: { + devDependencies: { + '@nx/angular': '22.7.2', + nx: '22.7.2', + }, + }, + packages: { + '@nx/angular@22.7.1': {}, + 'nx@22.7.2': {}, + }, + }); + + const result = runValidator(rootDir); + + assert.equal(result.status, 1); + assert.match( + result.stderr, + /resolved @nx\/angular@22\.7\.1 must match nx@22\.7\.2/ + ); +});