From ed40779f345704997e34348fed08cd712f819bd7 Mon Sep 17 00:00:00 2001 From: ysitbon Date: Fri, 24 Apr 2026 15:37:55 +0200 Subject: [PATCH 1/6] feat(nx-plugin): infer boundary tags for new-arch packages Extends the project-tags plugin so every package under domain/, shared/, and features/ carries the tags that LIVE-29780's upcoming module-boundary validator will rely on: - domain/entity/* -> scope:domain + type:domain-entity - domain/api/* -> scope:domain + type:domain-api - shared/* -> scope:shared - features/* -> scope:features (unchanged) Legacy libs/, apps/, e2e/, and tools/ inference is untouched. 8 node:test assertions lock both the new branches and the legacy regression paths. Refs: LIVE-29780 --- tools/nx-plugins/project-tags/plugin.js | 13 ++++ tools/nx-plugins/project-tags/plugin.test.js | 76 ++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tools/nx-plugins/project-tags/plugin.test.js diff --git a/tools/nx-plugins/project-tags/plugin.js b/tools/nx-plugins/project-tags/plugin.js index 6486566602a8..0df92373075f 100644 --- a/tools/nx-plugins/project-tags/plugin.js +++ b/tools/nx-plugins/project-tags/plugin.js @@ -33,6 +33,19 @@ function inferTags(projectRoot, packageName) { tags.add("scope:features"); } + if (projectRoot.startsWith("domain/")) { + tags.add("scope:domain"); + if (projectRoot.startsWith("domain/entity/")) { + tags.add("type:domain-entity"); + } else if (projectRoot.startsWith("domain/api/")) { + tags.add("type:domain-api"); + } + } + + if (projectRoot.startsWith("shared/")) { + tags.add("scope:shared"); + } + if (projectRoot.startsWith("e2e/")) { tags.add("scope:e2e"); } diff --git a/tools/nx-plugins/project-tags/plugin.test.js b/tools/nx-plugins/project-tags/plugin.test.js new file mode 100644 index 000000000000..856627283623 --- /dev/null +++ b/tools/nx-plugins/project-tags/plugin.test.js @@ -0,0 +1,76 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { inferTags } = require("./plugin"); + +test("domain/entity/* gets scope:domain + type:domain-entity", () => { + assert.deepEqual(inferTags("domain/entity/currency", "@domain/entity-currency"), [ + "scope:domain", + "scope:no-apps", + "type:domain-entity", + ]); + assert.deepEqual(inferTags("domain/entity/currency-fiat", "@domain/entity-currency-fiat"), [ + "scope:domain", + "scope:no-apps", + "type:domain-entity", + ]); +}); + +test("domain/api/* gets scope:domain + type:domain-api", () => { + assert.deepEqual(inferTags("domain/api/foo", "@domain/api-foo"), [ + "scope:domain", + "scope:no-apps", + "type:domain-api", + ]); +}); + +test("shared/* gets scope:shared", () => { + assert.deepEqual(inferTags("shared/feature-flags", "@shared/feature-flags"), [ + "scope:no-apps", + "scope:shared", + ]); + assert.deepEqual(inferTags("shared/schema-primitives", "@shared/schema-primitives"), [ + "scope:no-apps", + "scope:shared", + ]); +}); + +test("features/* gets scope:features (regression)", () => { + assert.deepEqual(inferTags("features/market-banner", "@features/market-banner"), [ + "scope:features", + "scope:no-apps", + ]); +}); + +test("libs/* does not pick up new-arch tags (regression)", () => { + const libTags = inferTags("libs/ledger-live-common", "@ledgerhq/live-common"); + assert.ok(!libTags.includes("scope:domain"), `got ${libTags.join(",")}`); + assert.ok(!libTags.includes("scope:shared")); + assert.ok(!libTags.includes("type:domain-entity")); + assert.ok(libTags.includes("scope:libs")); + assert.ok(libTags.includes("type:live-common")); +}); + +test("apps/* still get scope:apps and not scope:no-apps (regression)", () => { + const desktop = inferTags("apps/ledger-live-desktop", "ledger-live-desktop"); + assert.ok(desktop.includes("scope:apps")); + assert.ok(desktop.includes("type:app-desktop")); + assert.ok(!desktop.includes("scope:no-apps")); + assert.ok(!desktop.includes("scope:domain")); +}); + +test("libs/ui/* sub-scoping still works (regression)", () => { + assert.deepEqual(inferTags("libs/ui/packages/react", "@ledgerhq/ui-react"), [ + "scope:libs", + "scope:libs-ui", + "scope:no-apps", + ]); +}); + +test("domain path that is not entity/ or api/ still gets scope:domain only", () => { + assert.deepEqual(inferTags("domain/other/thing", "@domain/other-thing"), [ + "scope:domain", + "scope:no-apps", + ]); +}); From cff540ca2f9f8ce118277a37664ed3da1353043c Mon Sep 17 00:00:00 2001 From: ysitbon Date: Fri, 24 Apr 2026 16:16:28 +0200 Subject: [PATCH 2/6] feat(nx-plugin): enforce module boundaries via project graph Adds a linter-agnostic validator that walks the Nx project graph and verifies every workspace -> workspace edge against the new-arch layering rules. Runs via @nx/devkit's createProjectGraphAsync, no ESLint involved (the repo is migrating to oxlint; 143/149 packages already use it). Rules (DEP_CONSTRAINTS, defined in tools/nx-plugins/enforce-boundaries/ constraints.js): - scope:shared can depend on scope:shared only (leaf layer) - scope:domain can depend on scope:domain + scope:shared - scope:features can depend on scope:features + scope:domain + scope:shared - type:domain-entity can depend on type:domain-entity + scope:shared - type:domain-api can depend on type:domain-entity + type:domain-api + scope:shared --- .../nx-enforce-module-boundaries-new-arch.md | 12 ++ .github/workflows/test-libs-reusable.yml | 3 + package.json | 1 + .../enforce-boundaries/constraints.js | 30 +++ .../enforce-boundaries/project.json | 26 +++ .../nx-plugins/enforce-boundaries/validate.js | 72 +++++++ .../enforce-boundaries/validate.test.js | 191 ++++++++++++++++++ 7 files changed, 335 insertions(+) create mode 100644 .changeset/nx-enforce-module-boundaries-new-arch.md create mode 100644 tools/nx-plugins/enforce-boundaries/constraints.js create mode 100644 tools/nx-plugins/enforce-boundaries/project.json create mode 100644 tools/nx-plugins/enforce-boundaries/validate.js create mode 100644 tools/nx-plugins/enforce-boundaries/validate.test.js diff --git a/.changeset/nx-enforce-module-boundaries-new-arch.md b/.changeset/nx-enforce-module-boundaries-new-arch.md new file mode 100644 index 000000000000..25b34d0fdd18 --- /dev/null +++ b/.changeset/nx-enforce-module-boundaries-new-arch.md @@ -0,0 +1,12 @@ +--- +"@domain/entity-currency": minor +"@domain/entity-currency-crypto": minor +"@domain/entity-currency-fiat": minor +"@domain/entity-currency-token": minor +"@domain/entity-currency-unit": minor +"@shared/feature-flags": minor +"@shared/schema-primitives": minor +"@features/market-banner": minor +--- + +Enforce module boundaries on the new architecture (`domain/`, `shared/`, `features/`) via a pure Nx project-graph validator (LIVE-29780). The project-tags plugin now infers `scope:domain`, `scope:shared`, `type:domain-entity`, and `type:domain-api`; a cacheable `lint:boundaries` Nx target walks the graph and fails CI on any workspace→workspace edge that violates the layered rules (shared leaf, domain depends on shared, features depend on domain+shared, entities cannot import APIs). No ESLint involvement — stays aligned with the ongoing oxlint migration; config ports verbatim to `.oxlintrc.json` when `@nx/oxlint` publishes stable. diff --git a/.github/workflows/test-libs-reusable.yml b/.github/workflows/test-libs-reusable.yml index 830fe801f92c..e4520fa94a9b 100644 --- a/.github/workflows/test-libs-reusable.yml +++ b/.github/workflows/test-libs-reusable.yml @@ -168,6 +168,9 @@ jobs: - name: Lint affected libraries id: lint-libs run: pnpm exec nx run-many -t lint --exclude=ledger-live-desktop,live-mobile,@ledgerhq/live-cli,@ledgerhq/web-tools,ledger-live-desktop-e2e-tests,ledger-live-mobile-e2e-tests --parallel=3 --nxBail=false -- --quiet + - name: Enforce module boundaries + id: lint-boundaries + run: pnpm exec nx run enforce-boundaries:lint:boundaries - name: Check export rules run: pnpm exec nx run @ledgerhq/client-ids:check-export-rules continue-on-error: true diff --git a/package.json b/package.json index 2973209d0b21..821bfa3ee202 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "scripts": { "nx:write-cache-config": "node tools/nx/write-nx-cache-config.mjs", "postinstall": "pnpm run nx:write-cache-config", + "lint:boundaries": "nx run enforce-boundaries:lint:boundaries", "bump": "changeset version", "clean": "git clean -fdX", "changelog": "changeset add", diff --git a/tools/nx-plugins/enforce-boundaries/constraints.js b/tools/nx-plugins/enforce-boundaries/constraints.js new file mode 100644 index 000000000000..b477cb25f33f --- /dev/null +++ b/tools/nx-plugins/enforce-boundaries/constraints.js @@ -0,0 +1,30 @@ +"use strict"; + +/** + * Module-boundary dependency constraints for the new architecture + * (domain/, shared/, features/). Shape mirrors @nx/enforce-module-boundaries + * depConstraints so this config ports verbatim to .oxlintrc.json when + * @nx/oxlint publishes stable. + * + * A rule fires only when the source package has the sourceTag. Legacy + * packages without matching tags (libs/, apps/, e2e/, tools/) are + * unconstrained on purpose. + */ +const DEP_CONSTRAINTS = [ + { sourceTag: "scope:shared", onlyDependOnLibsWithTags: ["scope:shared"] }, + { sourceTag: "scope:domain", onlyDependOnLibsWithTags: ["scope:domain", "scope:shared"] }, + { + sourceTag: "scope:features", + onlyDependOnLibsWithTags: ["scope:features", "scope:domain", "scope:shared"], + }, + { + sourceTag: "type:domain-entity", + onlyDependOnLibsWithTags: ["type:domain-entity", "scope:shared"], + }, + { + sourceTag: "type:domain-api", + onlyDependOnLibsWithTags: ["type:domain-entity", "type:domain-api", "scope:shared"], + }, +]; + +module.exports = { DEP_CONSTRAINTS }; diff --git a/tools/nx-plugins/enforce-boundaries/project.json b/tools/nx-plugins/enforce-boundaries/project.json new file mode 100644 index 000000000000..cdccbadac398 --- /dev/null +++ b/tools/nx-plugins/enforce-boundaries/project.json @@ -0,0 +1,26 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "enforce-boundaries", + "projectType": "library", + "targets": { + "lint:boundaries": { + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "{workspaceRoot}/tools/nx-plugins/enforce-boundaries/**/*", + "{workspaceRoot}/tools/nx-plugins/project-tags/plugin.js", + "{workspaceRoot}/domain/**/package.json", + "{workspaceRoot}/shared/**/package.json", + "{workspaceRoot}/features/**/package.json", + "{workspaceRoot}/apps/**/package.json", + "{workspaceRoot}/libs/**/package.json", + "{workspaceRoot}/pnpm-workspace.yaml", + "{workspaceRoot}/nx.cache-config.json" + ], + "options": { + "cwd": "{workspaceRoot}", + "command": "node tools/nx-plugins/enforce-boundaries/validate.js" + } + } + } +} diff --git a/tools/nx-plugins/enforce-boundaries/validate.js b/tools/nx-plugins/enforce-boundaries/validate.js new file mode 100644 index 000000000000..32d044a8616c --- /dev/null +++ b/tools/nx-plugins/enforce-boundaries/validate.js @@ -0,0 +1,72 @@ +"use strict"; + +const { createProjectGraphAsync } = require("@nx/devkit"); +const { DEP_CONSTRAINTS } = require("./constraints"); + +/** + * @typedef {{ data?: { tags?: string[] } }} GraphNode + * @typedef {{ target: string }} GraphEdge + * @typedef {{ nodes: Record, dependencies: Record }} ProjectGraphLike + * @typedef {{ sourceName: string, sourceTag: string, target: string, targetTags: string[] }} Violation + */ + +/** + * Walk the Nx project graph and collect every edge that violates the + * DEP_CONSTRAINTS rules. Exported for unit testing against a synthetic graph. + * + * @param {ProjectGraphLike} graph + * @returns {Violation[]} + */ +function findViolations(graph) { + const violations = []; + + for (const [sourceName, edges] of Object.entries(graph.dependencies)) { + const sourceNode = graph.nodes[sourceName]; + if (!sourceNode) continue; + const sourceTags = sourceNode.data?.tags ?? []; + + for (const edge of edges) { + const targetNode = graph.nodes[edge.target]; + if (!targetNode) continue; // external / npm targets carry no tags; skip + const targetTags = targetNode.data?.tags ?? []; + + for (const { sourceTag, onlyDependOnLibsWithTags } of DEP_CONSTRAINTS) { + if (!sourceTags.includes(sourceTag)) continue; + const allowed = targetTags.some(t => onlyDependOnLibsWithTags.includes(t)); + if (!allowed) { + violations.push({ sourceName, sourceTag, target: edge.target, targetTags }); + } + } + } + } + + return violations; +} + +async function main() { + const graph = await createProjectGraphAsync({ exitOnError: true }); + const violations = findViolations(graph); + + if (violations.length > 0) { + console.error(`\n✗ ${violations.length} module-boundary violation(s):\n`); + for (const v of violations) { + const tgtTags = v.targetTags.length > 0 ? v.targetTags.join(", ") : "untagged"; + console.error(` ${v.sourceName} [${v.sourceTag}] → ${v.target} [${tgtTags}]`); + } + console.error( + "\nAllowed edges are defined in tools/nx-plugins/enforce-boundaries/constraints.js\n", + ); + process.exit(1); + } + + console.log("✓ module boundaries ok"); +} + +module.exports = { findViolations }; + +if (require.main === module) { + main().catch(err => { + console.error(err); + process.exit(1); + }); +} diff --git a/tools/nx-plugins/enforce-boundaries/validate.test.js b/tools/nx-plugins/enforce-boundaries/validate.test.js new file mode 100644 index 000000000000..5f38fc081e51 --- /dev/null +++ b/tools/nx-plugins/enforce-boundaries/validate.test.js @@ -0,0 +1,191 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { findViolations } = require("./validate"); + +/** + * @param {Array<{name: string, tags: string[]}>} projects + * @param {Array<{source: string, target: string}>} edges + */ +function buildGraph(projects, edges) { + const nodes = Object.fromEntries(projects.map(p => [p.name, { data: { tags: p.tags } }])); + const dependencies = Object.fromEntries( + projects.map(p => [ + p.name, + edges.filter(e => e.source === p.name).map(e => ({ target: e.target })), + ]), + ); + return { nodes, dependencies }; +} + +test("shared -> shared is allowed", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:shared"] }, + { name: "b", tags: ["scope:shared"] }, + ], + [{ source: "a", target: "b" }], + ); + assert.deepEqual(findViolations(graph), []); +}); + +test("shared -> domain is forbidden (leaf-layer rule)", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:shared"] }, + { name: "b", tags: ["scope:domain", "type:domain-entity"] }, + ], + [{ source: "a", target: "b" }], + ); + const v = findViolations(graph); + assert.equal(v.length, 1); + assert.equal(v[0].sourceName, "a"); + assert.equal(v[0].sourceTag, "scope:shared"); + assert.equal(v[0].target, "b"); +}); + +test("domain -> shared is allowed", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:domain", "type:domain-api"] }, + { name: "b", tags: ["scope:shared"] }, + ], + [{ source: "a", target: "b" }], + ); + assert.deepEqual(findViolations(graph), []); +}); + +test("domain-entity -> domain-api is forbidden (intra-domain rule)", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:domain", "type:domain-entity"] }, + { name: "b", tags: ["scope:domain", "type:domain-api"] }, + ], + [{ source: "a", target: "b" }], + ); + const v = findViolations(graph); + assert.equal(v.length, 1); + assert.equal(v[0].sourceTag, "type:domain-entity"); + assert.equal(v[0].target, "b"); +}); + +test("domain-api -> domain-entity is allowed", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:domain", "type:domain-api"] }, + { name: "b", tags: ["scope:domain", "type:domain-entity"] }, + ], + [{ source: "a", target: "b" }], + ); + assert.deepEqual(findViolations(graph), []); +}); + +test("domain-entity -> domain-entity is allowed", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:domain", "type:domain-entity"] }, + { name: "b", tags: ["scope:domain", "type:domain-entity"] }, + ], + [{ source: "a", target: "b" }], + ); + assert.deepEqual(findViolations(graph), []); +}); + +test("features -> domain is allowed", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:features"] }, + { name: "b", tags: ["scope:domain", "type:domain-api"] }, + ], + [{ source: "a", target: "b" }], + ); + assert.deepEqual(findViolations(graph), []); +}); + +test("features -> shared is allowed", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:features"] }, + { name: "b", tags: ["scope:shared"] }, + ], + [{ source: "a", target: "b" }], + ); + assert.deepEqual(findViolations(graph), []); +}); + +test("features -> legacy libs is forbidden (features must stay in new arch)", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:features"] }, + { name: "b", tags: ["scope:libs", "scope:libs-non-ui"] }, + ], + [{ source: "a", target: "b" }], + ); + const v = findViolations(graph); + assert.equal(v.length, 1); + assert.equal(v[0].sourceTag, "scope:features"); +}); + +test("legacy source (no new-arch tags) is unconstrained", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:libs", "scope:libs-non-ui"] }, + { name: "b", tags: ["scope:domain", "type:domain-entity"] }, + ], + [{ source: "a", target: "b" }], + ); + assert.deepEqual(findViolations(graph), []); +}); + +test("app source is unconstrained (apps can import anything during migration)", () => { + const graph = buildGraph( + [ + { name: "app", tags: ["scope:apps", "type:app-desktop"] }, + { name: "b", tags: ["scope:domain", "type:domain-entity"] }, + { name: "c", tags: ["scope:features"] }, + ], + [ + { source: "app", target: "b" }, + { source: "app", target: "c" }, + ], + ); + assert.deepEqual(findViolations(graph), []); +}); + +test("external deps (target absent from graph.nodes) are skipped", () => { + const graph = { + nodes: { a: { data: { tags: ["scope:shared"] } } }, + dependencies: { a: [{ target: "npm:some-package" }, { target: "npm:another" }] }, + }; + assert.deepEqual(findViolations(graph), []); +}); + +test("multiple violations are all reported", () => { + const graph = buildGraph( + [ + { name: "a", tags: ["scope:shared"] }, + { name: "b", tags: ["scope:domain"] }, + { name: "c", tags: ["scope:features"] }, + ], + [ + { source: "a", target: "b" }, + { source: "a", target: "c" }, + ], + ); + const v = findViolations(graph); + assert.equal(v.length, 2); + assert.ok(v.every(x => x.sourceName === "a" && x.sourceTag === "scope:shared")); +}); + +test("missing tags default to empty array (no crash)", () => { + const graph = { + nodes: { + a: { data: {} }, + b: {}, + }, + dependencies: { a: [{ target: "b" }] }, + }; + // neither node carries tags; no rule fires + assert.deepEqual(findViolations(graph), []); +}); From 43683f1652569b7188328da3739b864eeea1c7c1 Mon Sep 17 00:00:00 2001 From: ysitbon Date: Wed, 29 Apr 2026 17:47:18 +0200 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20de-dup=20tag=20inference,=20dedicated=20boundary=20check=20w?= =?UTF-8?q?orkflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inferTags: drop the duplicate domain/ and shared/ blocks. The type-tag-aware blocks higher up already emit scope:domain / scope:shared (alongside type:domain-entity / type:domain-api), so behavior is preserved — just clearer to read. CI: extract the boundary check into its own reusable workflow at .github/workflows/test-boundaries-reusable.yml and call it from build-and-test-pr.yml as a dedicated enforce-boundaries job, gated on PRs touching domain/, shared/, or features/. Removes the now- redundant step from test-libs-reusable.yml. The new workflow is temporarily called via @ci/nx-enforce-module-boundaries-new-arch so this PR can prove it runs end-to-end. MUST be flipped back to @develop before merge. Refs: LIVE-29780 --- .github/workflows/build-and-test-pr.yml | 7 +++ .../workflows/test-boundaries-reusable.yml | 49 +++++++++++++++++++ .github/workflows/test-libs-reusable.yml | 3 -- tools/nx-plugins/project-tags/plugin.js | 8 --- 4 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/test-boundaries-reusable.yml diff --git a/.github/workflows/build-and-test-pr.yml b/.github/workflows/build-and-test-pr.yml index ef7f72c3ec9a..f05d830a02bd 100644 --- a/.github/workflows/build-and-test-pr.yml +++ b/.github/workflows/build-and-test-pr.yml @@ -108,6 +108,13 @@ jobs: uses: LedgerHQ/ledger-live/.github/workflows/test-domain-reusable.yml@develop secrets: inherit + enforce-boundaries: + name: "Enforce Module Boundaries" + needs: determine-affected + if: ${{(contains(needs.determine-affected.outputs.paths, 'domain') || contains(needs.determine-affected.outputs.paths, 'shared') || contains(needs.determine-affected.outputs.paths, 'features')) && !github.event.pull_request.head.repo.fork}} + uses: LedgerHQ/ledger-live/.github/workflows/test-boundaries-reusable.yml@ci/nx-enforce-module-boundaries-new-arch + secrets: inherit + test-design-system: name: "Test UI Libs" needs: determine-affected diff --git a/.github/workflows/test-boundaries-reusable.yml b/.github/workflows/test-boundaries-reusable.yml new file mode 100644 index 000000000000..e211eaf95490 --- /dev/null +++ b/.github/workflows/test-boundaries-reusable.yml @@ -0,0 +1,49 @@ +name: "[Module Boundaries] - Test - Called" + +on: + workflow_call: + workflow_dispatch: + inputs: + ref: + description: | + If you run this manually, and want to run on a PR, the correct ref should be refs/pull/{PR_NUMBER}/merge to + have the "normal" scenario involving checking out a merge commit between your branch and the base branch. + If you want to run only on a branch or specific commit, you can use either the sha or the branch name instead (prefer the first version for PRs). + required: false + +permissions: + id-token: write + contents: read + +jobs: + enforce-boundaries: + name: Enforce Module Boundaries + runs-on: ubuntu-22.04 + timeout-minutes: 10 + env: + NODE_OPTIONS: "--max-old-space-size=4096" + CI_OS: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref || github.sha }} + + - name: Setup the caches + uses: LedgerHQ/ledger-live/tools/actions/composites/setup-caches@develop + id: setup-caches + with: + use-mise: true + gh-token: ${{ secrets.GITHUB_TOKEN }} + cache-develop-role-arn: ${{ secrets.AWS_CACHE_OIDC_ROLE_ARN_DEVELOP }} + cache-branch-role-arn: ${{ secrets.AWS_CACHE_OIDC_ROLE_ARN_BRANCH }} + nx-key: ${{ secrets.NX_KEY }} + accountId: ${{ secrets.AWS_ACCOUNT_ID_PROD }} + roleName: ${{ secrets.AWS_CACHE_ROLE_NAME }} + region: ${{ secrets.AWS_CACHE_REGION }} + + - name: Install dependencies + run: pnpm i + + - name: Enforce module boundaries + run: pnpm exec nx run enforce-boundaries:lint:boundaries diff --git a/.github/workflows/test-libs-reusable.yml b/.github/workflows/test-libs-reusable.yml index e4520fa94a9b..830fe801f92c 100644 --- a/.github/workflows/test-libs-reusable.yml +++ b/.github/workflows/test-libs-reusable.yml @@ -168,9 +168,6 @@ jobs: - name: Lint affected libraries id: lint-libs run: pnpm exec nx run-many -t lint --exclude=ledger-live-desktop,live-mobile,@ledgerhq/live-cli,@ledgerhq/web-tools,ledger-live-desktop-e2e-tests,ledger-live-mobile-e2e-tests --parallel=3 --nxBail=false -- --quiet - - name: Enforce module boundaries - id: lint-boundaries - run: pnpm exec nx run enforce-boundaries:lint:boundaries - name: Check export rules run: pnpm exec nx run @ledgerhq/client-ids:check-export-rules continue-on-error: true diff --git a/tools/nx-plugins/project-tags/plugin.js b/tools/nx-plugins/project-tags/plugin.js index 0df92373075f..5de8be1ebde5 100644 --- a/tools/nx-plugins/project-tags/plugin.js +++ b/tools/nx-plugins/project-tags/plugin.js @@ -54,14 +54,6 @@ function inferTags(projectRoot, packageName) { tags.add("scope:tools"); } - if (projectRoot.startsWith("shared/")) { - tags.add("scope:shared"); - } - - if (projectRoot.startsWith("domain/")) { - tags.add("scope:domain"); - } - if (!(projectRoot === "apps" || projectRoot.startsWith("apps/"))) { tags.add("scope:no-apps"); } From a61c52f590e8ff3963bf1698f8685356437f786c Mon Sep 17 00:00:00 2001 From: ysitbon Date: Wed, 29 Apr 2026 18:06:25 +0200 Subject: [PATCH 4/6] fix(nx-plugin): dedupe boundary violations per source/target edge A source project can match multiple depConstraints (e.g. a type:domain-api project also has scope:domain). Before this fix findViolations emitted one violation per matching rule, so the same forbidden edge could surface twice in CI output and inflate the violation count. Now keyed by (sourceName, target): one entry per offending edge with the failing source tags accumulated. Output formatting joins the source tags with a comma. Adds a node:test case covering the multi- tag dedup; existing single-tag tests adjusted from sourceTag (string) to sourceTags (array). Refs: LIVE-29780 --- .../nx-plugins/enforce-boundaries/validate.js | Bin 2338 -> 2736 bytes .../enforce-boundaries/validate.test.js | 27 +++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/tools/nx-plugins/enforce-boundaries/validate.js b/tools/nx-plugins/enforce-boundaries/validate.js index 32d044a8616c91b30a71288eea44e768534c508d..c996f6224347b54ee8a6991f199e31659de58bf1 100644 GIT binary patch delta 575 zcmYjOKTE?v6t@Kts#8Vhr*uf9-Un!LaB$SgtrYT1UXs(~F61teT0+6mN$Bh%6kG(M zU&im^;$0I{Z#cMjzrT0y{ja01=gz(wfzmk_(PX-O>AWp2A>t(opdcdfQ9z2?k0C7* z&9lUeq%e5oLaQE}fkP}}fZ*v_Mx#Kc=H?tT?tzj;?h|05B*dt|Q@(5=o;wB|lT`={ z;cG4hOptOA6{6N9o!jibhBIq0tXce5+o z;Q|Cb!Zl`&yZ-LJb{Ci=@{t19R?%D(H174FS(|q7Kr=)02J>3+22-kb26Ou)Ok*CA zuDo%}Nk~I!@UeRD1QTE@+0oK=hfh> domain is forbidden (leaf-layer rule)", () => { const v = findViolations(graph); assert.equal(v.length, 1); assert.equal(v[0].sourceName, "a"); - assert.equal(v[0].sourceTag, "scope:shared"); + assert.deepEqual(v[0].sourceTags, ["scope:shared"]); assert.equal(v[0].target, "b"); }); @@ -66,7 +66,7 @@ test("domain-entity -> domain-api is forbidden (intra-domain rule)", () => { ); const v = findViolations(graph); assert.equal(v.length, 1); - assert.equal(v[0].sourceTag, "type:domain-entity"); + assert.deepEqual(v[0].sourceTags, ["type:domain-entity"]); assert.equal(v[0].target, "b"); }); @@ -124,7 +124,7 @@ test("features -> legacy libs is forbidden (features must stay in new arch)", () ); const v = findViolations(graph); assert.equal(v.length, 1); - assert.equal(v[0].sourceTag, "scope:features"); + assert.deepEqual(v[0].sourceTags, ["scope:features"]); }); test("legacy source (no new-arch tags) is unconstrained", () => { @@ -175,7 +175,26 @@ test("multiple violations are all reported", () => { ); const v = findViolations(graph); assert.equal(v.length, 2); - assert.ok(v.every(x => x.sourceName === "a" && x.sourceTag === "scope:shared")); + assert.ok(v.every(x => x.sourceName === "a" && x.sourceTags.includes("scope:shared"))); +}); + +test("multi-tag source emits one deduped violation per edge", () => { + // type:domain-api source also carries scope:domain — both rules match, + // both forbid scope:features targets. Should produce ONE violation + // listing both source tags. + const graph = buildGraph( + [ + { name: "a", tags: ["scope:domain", "type:domain-api"] }, + { name: "b", tags: ["scope:features"] }, + ], + [{ source: "a", target: "b" }], + ); + const v = findViolations(graph); + assert.equal(v.length, 1); + assert.equal(v[0].sourceName, "a"); + assert.equal(v[0].target, "b"); + assert.ok(v[0].sourceTags.includes("scope:domain")); + assert.ok(v[0].sourceTags.includes("type:domain-api")); }); test("missing tags default to empty array (no crash)", () => { From 2e4ad319b9560c413ed5634cbe69b12ef71afd63 Mon Sep 17 00:00:00 2001 From: ysitbon Date: Wed, 29 Apr 2026 18:52:24 +0200 Subject: [PATCH 5/6] chore: restore workflow pin version to develop --- .github/workflows/build-and-test-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test-pr.yml b/.github/workflows/build-and-test-pr.yml index f05d830a02bd..2c84df621d9d 100644 --- a/.github/workflows/build-and-test-pr.yml +++ b/.github/workflows/build-and-test-pr.yml @@ -112,7 +112,7 @@ jobs: name: "Enforce Module Boundaries" needs: determine-affected if: ${{(contains(needs.determine-affected.outputs.paths, 'domain') || contains(needs.determine-affected.outputs.paths, 'shared') || contains(needs.determine-affected.outputs.paths, 'features')) && !github.event.pull_request.head.repo.fork}} - uses: LedgerHQ/ledger-live/.github/workflows/test-boundaries-reusable.yml@ci/nx-enforce-module-boundaries-new-arch + uses: LedgerHQ/ledger-live/.github/workflows/test-boundaries-reusable.yml@develop secrets: inherit test-design-system: From 7c4b507e266a4b29ba64e73653b9682a029261ef Mon Sep 17 00:00:00 2001 From: ysitbon Date: Thu, 30 Apr 2026 10:06:47 +0200 Subject: [PATCH 6/6] chore: delete unecessary changeset --- .changeset/nx-enforce-module-boundaries-new-arch.md | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .changeset/nx-enforce-module-boundaries-new-arch.md diff --git a/.changeset/nx-enforce-module-boundaries-new-arch.md b/.changeset/nx-enforce-module-boundaries-new-arch.md deleted file mode 100644 index 25b34d0fdd18..000000000000 --- a/.changeset/nx-enforce-module-boundaries-new-arch.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@domain/entity-currency": minor -"@domain/entity-currency-crypto": minor -"@domain/entity-currency-fiat": minor -"@domain/entity-currency-token": minor -"@domain/entity-currency-unit": minor -"@shared/feature-flags": minor -"@shared/schema-primitives": minor -"@features/market-banner": minor ---- - -Enforce module boundaries on the new architecture (`domain/`, `shared/`, `features/`) via a pure Nx project-graph validator (LIVE-29780). The project-tags plugin now infers `scope:domain`, `scope:shared`, `type:domain-entity`, and `type:domain-api`; a cacheable `lint:boundaries` Nx target walks the graph and fails CI on any workspace→workspace edge that violates the layered rules (shared leaf, domain depends on shared, features depend on domain+shared, entities cannot import APIs). No ESLint involvement — stays aligned with the ongoing oxlint migration; config ports verbatim to `.oxlintrc.json` when `@nx/oxlint` publishes stable.