Skip to content

Commit

Permalink
feat: enables matching transitive dependencies in 'required' rules (#975
Browse files Browse the repository at this point in the history
)

## Description

- [x] adds the `reachable` attribute to the 'required' rules schema
- [x] adds reachable detection to the enrich step
- [x] processes `reachable` in validating against 'required' rules
- [x] update the documentation

## Motivation and Context

See #973 

## How Has This Been Tested?

- [x] green ci
- [x] additional automated non-regression tests

## Example usage

```javascript
/** @type {import('dependency-cruiser').IConfiguration} */
export default {
  required: [
    {
      name: "must-reach-meta-cjs",
      comment: "all modules much reach, somehow, meta.cjs",
      module: {
        path: "^src/",
      },
      to: {
        path: "^src/meta[.]cjs$",
        reachable: true,
      }
    }
  ],
  options: {
    // ...
  }
}
```

## Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Documentation only change
- [ ] Refactor (non-breaking change which fixes an issue without
changing functionality)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
  • Loading branch information
sverweij authored Dec 12, 2024
1 parent fdbb72a commit dd81580
Show file tree
Hide file tree
Showing 11 changed files with 317 additions and 18 deletions.
39 changes: 36 additions & 3 deletions doc/rules-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ controller _must depend on_ the base controller'.
modules the rule applies to and the `to` describes what dependencies that module
should exactly have.

To check for _direct_ dependencies (controllers must directly depend on the
base controller):

```javascript
{
required: [
Expand All @@ -102,18 +105,48 @@ should exactly have.
"Each controller should inherit from the framework's base controller",
module: {
// every module that matches the pattern specified in path & pathNot ...
path: "-controller\\.js$",
pathNot: "framework/base-controller\\.js$",
path: "-controller[.]js$",
pathNot: "framework/base-controller[.]js$",
},
to: {
// ... must depend at least once on the framework's base controller
path: "^src/framework/base-controller[.]js$",
},
},
];
}
```

To check for _indirect_ ('transitive') dependencies (controllers must depend
on the base controller, either directly or via other modules) add the `reachable`
attribute to `to` part of the rule, like so:

```javascript
{
required: [
{
name: "controllers-transitively-inherit-from-base",
comment:
"Each controller should inherit from the framework's base controller",
module: {
// every module that matches the pattern specified in path & pathNot ...
path: "-controller[.]js$",
pathNot: "framework/base-controller[.]js$",
},
to: {
// ... must depend at least once on the framework's base controller
path: "^src/framework/base-controller\\.js$",
path: "^src/framework/base-controller[.]js$",
// ... either directly or indirectly
reachable: true,
},
},
];
}
```

> `reachable: false` is the same as leaving the reachable attribute out; so it
> does 'nothing'.
### `extends`

This takes one or more file path to other dependency-cruiser-configs. When
Expand Down
26 changes: 21 additions & 5 deletions src/enrich/derive/reachable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,30 @@ function getReachableRules(pRuleSet) {
(pRuleSet?.allowed ?? []).filter((pRule) =>
Object.hasOwn(pRule?.to ?? {}, "reachable"),
),
)
.concat(
(pRuleSet?.required ?? []).filter((pRule) =>
Object.hasOwn(pRule?.to ?? {}, "reachable"),
),
);
}

function isModuleInRuleFrom(pRule) {
return (pModule) =>
(!pRule.from.path || pModule.source.match(pRule.from.path)) &&
(!pRule.from.pathNot || !pModule.source.match(pRule.from.pathNot));
return (pModule) => {
const lRuleFrom = pRule.from ?? pRule.module;
if (lRuleFrom) {
return (
(!lRuleFrom.path || pModule.source.match(lRuleFrom.path)) &&
(!lRuleFrom.pathNot || !pModule.source.match(lRuleFrom.pathNot))
);
}
return false;
};
}

function isModuleInRuleTo(pRule, pModuleTo, pModuleFrom) {
const lGroups = pModuleFrom
? extractGroups(pRule.from, pModuleFrom.source)
? extractGroups(pRule.from ?? pRule.module, pModuleFrom.source)
: [];

return (
Expand Down Expand Up @@ -91,7 +103,11 @@ function hasCapturingGroups(pRule) {
function shouldAddReachable(pRule, pModuleTo, pGraph) {
let lReturnValue = false;

if (pRule.to.reachable === false || pRule.name === "not-in-allowed") {
if (
pRule.to.reachable === false ||
pRule.name === "not-in-allowed" ||
pRule.module
) {
if (hasCapturingGroups(pRule)) {
const lModulesFrom = pGraph.filter(isModuleInRuleFrom(pRule));

Expand Down
4 changes: 4 additions & 0 deletions src/schema/configuration.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/schema/configuration.schema.mjs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/schema/cruise-result.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/schema/cruise-result.schema.mjs

Large diffs are not rendered by default.

18 changes: 14 additions & 4 deletions src/validate/violates-required-rule.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import {
matchesToPath,
matchesModulePath,
matchesModulePathNot,
matchToModulePath,
} from "./matchers.mjs";
import { matchesReachesRule } from "./match-module-rule-helpers.mjs";
import { extractGroups } from "#utl/regex-util.mjs";

/**
Expand All @@ -20,11 +22,19 @@ export default function violatesRequiredRule(pRule, pModule) {
matchesModulePath(pRule, pModule) &&
matchesModulePathNot(pRule, pModule)
) {
const lGroups = extractGroups(pRule.module, pModule.source);
if (pRule.to.reachable) {
lReturnValue = !matchesReachesRule(pRule, pModule);
}

lReturnValue = !pModule.dependencies.some((pDependency) =>
matchesToPath(pRule, pDependency, lGroups),
);
if (lReturnValue || !pRule.to.reachable) {
const lGroups = extractGroups(pRule.module, pModule.source);
const lMatchesSelf = matchToModulePath(pRule, pModule, lGroups);
lReturnValue =
!lMatchesSelf &&
!pModule.dependencies.some((pDependency) =>
matchesToPath(pRule, pDependency, lGroups),
);
}
}
return lReturnValue;
}
88 changes: 85 additions & 3 deletions test/enrich/derive/reachable.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,17 @@ describe("[U] enrich/derive/reachable/index - reachability detection", () => {
deepEqual(addReachability(GRAPH, normalize({})), GRAPH);
});

it('returns the reachability annotated graph when a rule set with forbidden "reachable" in it', () => {
it("returns the input graph when passed a rule set with only a rul that doesn't have a 'from' or a 'module'", () => {
deepEqual(
addReachability(
GRAPH,
normalize({ required: [{ thing: {}, to: { reachable: true } }] }),
),
GRAPH,
);
});

it('returns the reachability annotated graph when a rule set with forbidden "reachable" in it (forbidden rule)', () => {
const lForbiddenReachabilityRuleSetHajoo = {
forbidden: [
{
Expand All @@ -134,8 +144,8 @@ describe("[U] enrich/derive/reachable/index - reachability detection", () => {
const lForbiddenReachabilityRuleSetHajoo = {
allowed: [
{
from: { path: "src/[^.]+\\.js" },
to: { path: "./src/hajoo\\.js$", reachable: true },
from: { path: "src/[^.]+[.]js" },
to: { path: "./src/hajoo[.]js$", reachable: true },
},
],
};
Expand Down Expand Up @@ -274,6 +284,78 @@ describe("[U] enrich/derive/reachable/index - reachability detection", () => {
);
});

it('returns the reachability annotated graph when a rule set with allowed "reachable" in it ("required" rules)', () => {
const lRequiredReachabilityRuleSetHajoo = {
required: [
{
name: "must-reach-hajoo-somehow",
module: { path: "src/[^.]+[.]js" },
to: { path: "./src/hajoo[.]js$", reachable: true },
},
],
};
const lExpected = [
{
source: "./src/index.js",
dependencies: [
{
resolved: "./src/intermediate.js",
},
],
reaches: [
{
asDefinedInRule: "must-reach-hajoo-somehow",
modules: [
{
source: "./src/hajoo.js",
via: [
{ name: "./src/intermediate.js", dependencyTypes: [] },
{ name: "./src/hajoo.js", dependencyTypes: [] },
],
},
],
},
],
},
{
source: "./src/intermediate.js",
dependencies: [
{
resolved: "./src/index.js",
},
{
resolved: "./src/hajoo.js",
},
],
reaches: [
{
asDefinedInRule: "must-reach-hajoo-somehow",
modules: [
{
source: "./src/hajoo.js",
via: [{ name: "./src/hajoo.js", dependencyTypes: [] }],
},
],
},
],
},
{
source: "./src/hajoo.js",
dependencies: [],
reachable: [
{
value: true,
matchedFrom: "./src/index.js",
asDefinedInRule: "must-reach-hajoo-somehow",
},
],
},
];
const lNormalizedGraph = normalize(lRequiredReachabilityRuleSetHajoo);
const lResult = addReachability(GRAPH, lNormalizedGraph);
deepEqual(lResult, lExpected);
});

it('returns the reachability annotated graph when a rule set with allowed "reachable" in it (without a rule name - multiple matches)', () => {
const lForbiddenReachabilityRuleSetHajoo = {
allowed: [
Expand Down
Loading

0 comments on commit dd81580

Please sign in to comment.