Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/build-and-test-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Comment thread
LL782 marked this conversation as resolved.
uses: LedgerHQ/ledger-live/.github/workflows/test-boundaries-reusable.yml@develop
secrets: inherit
Comment thread
ysitbon marked this conversation as resolved.

test-design-system:
name: "Test UI Libs"
needs: determine-affected
Expand Down
49 changes: 49 additions & 0 deletions .github/workflows/test-boundaries-reusable.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
ysitbon marked this conversation as resolved.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions tools/nx-plugins/enforce-boundaries/constraints.js
Original file line number Diff line number Diff line change
@@ -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"],
Comment thread
ysitbon marked this conversation as resolved.
},
];

module.exports = { DEP_CONSTRAINTS };
26 changes: 26 additions & 0 deletions tools/nx-plugins/enforce-boundaries/project.json
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
ysitbon marked this conversation as resolved.
"{workspaceRoot}/nx.cache-config.json"
],
"options": {
"cwd": "{workspaceRoot}",
"command": "node tools/nx-plugins/enforce-boundaries/validate.js"
}
}
}
}
Binary file added tools/nx-plugins/enforce-boundaries/validate.js
Comment thread
LL782 marked this conversation as resolved.
Binary file not shown.
210 changes: 210 additions & 0 deletions tools/nx-plugins/enforce-boundaries/validate.test.js
Comment thread
ysitbon marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"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.deepEqual(v[0].sourceTags, ["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.deepEqual(v[0].sourceTags, ["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.deepEqual(v[0].sourceTags, ["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.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)", () => {
const graph = {
nodes: {
a: { data: {} },
b: {},
},
dependencies: { a: [{ target: "b" }] },
};
// neither node carries tags; no rule fires
assert.deepEqual(findViolations(graph), []);
});
21 changes: 13 additions & 8 deletions tools/nx-plugins/project-tags/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,25 @@ function inferTags(projectRoot, packageName) {
tags.add("scope:features");
}

if (projectRoot.startsWith("e2e/")) {
tags.add("scope:e2e");
}

if (projectRoot.startsWith("tools/")) {
tags.add("scope:tools");
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");
Comment thread
ysitbon marked this conversation as resolved.
}
}

if (projectRoot.startsWith("shared/")) {
tags.add("scope:shared");
}

if (projectRoot.startsWith("domain/")) {
tags.add("scope:domain");
if (projectRoot.startsWith("e2e/")) {
tags.add("scope:e2e");
}

if (projectRoot.startsWith("tools/")) {
tags.add("scope:tools");
}

if (!(projectRoot === "apps" || projectRoot.startsWith("apps/"))) {
Expand Down
Loading
Loading