Skip to content

Commit f6692fc

Browse files
barbados-clemensclaudenx-cloud[bot]
authored
fix(core): handle owners and conformance project refs on move/remove (#34815)
## Current Behavior When removing or moving a project in an Nx workspace, the `owners` and `conformance` sections in `nx.json` are not updated. This leaves stale project references in: - `conformance.rules[].projects` (both plain strings and `{ matcher }` objects) - `owners.patterns[].projects` (top-level and section-level for GitLab) ## Expected Behavior - **On project removal**: Strip the removed project from all conformance rules and owners patterns. Remove entries that become empty after cleanup (rules with no projects, patterns with no projects). - **On project move/rename**: Rename all references to the old project name with the new name in conformance rules and owners patterns (including section-level patterns for GitLab-style CODEOWNERS). - **Schema**: Add `owners` configuration schema to `nx-schema.json` for validation and IDE support, including sections (GitLab CODEOWNERS). ## Related Issue(s) <!-- No specific issue linked --> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com> Co-authored-by: barbados-clemens <[email protected]>
1 parent 6cc8735 commit f6692fc

6 files changed

Lines changed: 1092 additions & 1 deletion

File tree

packages/nx/schemas/nx-schema.json

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,9 +548,111 @@
548548
},
549549
"required": ["rules"],
550550
"additionalProperties": false
551+
},
552+
"owners": {
553+
"description": "Configuration for Nx Owners (CODEOWNERS generation). Set to true to enable with all defaults.",
554+
"oneOf": [
555+
{
556+
"type": "boolean",
557+
"enum": [true],
558+
"description": "Enable owners with all default settings."
559+
},
560+
{
561+
"type": "object",
562+
"properties": {
563+
"format": {
564+
"type": "string",
565+
"description": "The format to use for the generated owners file. Defaults to 'github'.",
566+
"enum": ["github", "gitlab", "bitbucket"],
567+
"default": "github"
568+
},
569+
"outputPath": {
570+
"type": "string",
571+
"description": "The path to write the generated owners file to. Defaults based on format (e.g. '.github/CODEOWNERS')."
572+
},
573+
"patterns": {
574+
"type": "array",
575+
"description": "List of ownership patterns to apply.",
576+
"items": {
577+
"$ref": "#/definitions/ownersPattern"
578+
}
579+
},
580+
"sections": {
581+
"type": "array",
582+
"description": "List of ownership sections (GitLab only). Sections allow grouping patterns with approval requirements.",
583+
"items": {
584+
"type": "object",
585+
"properties": {
586+
"name": {
587+
"type": "string",
588+
"description": "The section label."
589+
},
590+
"defaultOwners": {
591+
"type": "array",
592+
"description": "Default owners for all patterns in this section.",
593+
"items": {
594+
"type": "string"
595+
}
596+
},
597+
"numberOfRequiredApprovals": {
598+
"type": "number",
599+
"description": "Number of required approvals for this section. Mutually exclusive with 'optional'."
600+
},
601+
"optional": {
602+
"type": "boolean",
603+
"description": "Whether this section is optional. Mutually exclusive with 'numberOfRequiredApprovals'."
604+
},
605+
"patterns": {
606+
"type": "array",
607+
"description": "List of ownership patterns within this section.",
608+
"items": {
609+
"$ref": "#/definitions/ownersPattern"
610+
}
611+
}
612+
},
613+
"required": ["name"],
614+
"additionalProperties": false
615+
}
616+
}
617+
},
618+
"additionalProperties": false
619+
}
620+
]
551621
}
552622
},
553623
"definitions": {
624+
"ownersPattern": {
625+
"type": "object",
626+
"properties": {
627+
"description": {
628+
"type": "string",
629+
"description": "A human-readable description of this ownership pattern."
630+
},
631+
"projects": {
632+
"type": "array",
633+
"description": "The projects this pattern applies to. Accepts project names, glob patterns, tag references, or any other valid project filter supported by the --projects flag.",
634+
"items": {
635+
"type": "string"
636+
}
637+
},
638+
"files": {
639+
"type": "array",
640+
"description": "File glob patterns this ownership pattern applies to (for non-project-based ownership).",
641+
"items": {
642+
"type": "string"
643+
}
644+
},
645+
"owners": {
646+
"type": "array",
647+
"description": "List of owners (e.g. GitHub usernames or team handles) for the matched projects or files.",
648+
"items": {
649+
"type": "string"
650+
}
651+
}
652+
},
653+
"required": ["owners"],
654+
"additionalProperties": false
655+
},
554656
"inputs": {
555657
"type": "array",
556658
"items": {
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { readNxJson, Tree, updateNxJson } from '@nx/devkit';
2+
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
3+
import { NormalizedSchema } from '../schema';
4+
import { updateOwnersAndConformance } from './update-owners-and-conformance';
5+
6+
describe('updateOwnersAndConformance', () => {
7+
let tree: Tree;
8+
let schema: NormalizedSchema;
9+
10+
beforeEach(() => {
11+
schema = {
12+
projectName: 'my-lib',
13+
destination: 'shared/my-destination',
14+
importPath: '@proj/my-destination',
15+
updateImportPath: true,
16+
newProjectName: 'my-destination',
17+
relativeToRootDestination: 'libs/shared/my-destination',
18+
};
19+
20+
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
21+
});
22+
23+
describe('conformance rules', () => {
24+
it('should rename project in conformance rule projects', () => {
25+
updateNxJson(tree, {
26+
...readNxJson(tree),
27+
conformance: {
28+
rules: [
29+
{
30+
rule: './some-rule',
31+
projects: ['my-lib', 'other-lib'],
32+
},
33+
],
34+
},
35+
} as any);
36+
37+
updateOwnersAndConformance(tree, schema);
38+
39+
const nxJson = readNxJson(tree) as any;
40+
expect(nxJson.conformance.rules[0].projects).toEqual([
41+
'my-destination',
42+
'other-lib',
43+
]);
44+
});
45+
46+
it('should rename project in conformance rule with matcher objects', () => {
47+
updateNxJson(tree, {
48+
...readNxJson(tree),
49+
conformance: {
50+
rules: [
51+
{
52+
rule: './some-rule',
53+
projects: [
54+
{ matcher: 'my-lib', explanation: 'reason' },
55+
{ matcher: 'other-lib' },
56+
],
57+
},
58+
],
59+
},
60+
} as any);
61+
62+
updateOwnersAndConformance(tree, schema);
63+
64+
const nxJson = readNxJson(tree) as any;
65+
expect(nxJson.conformance.rules[0].projects).toEqual([
66+
{ matcher: 'my-destination', explanation: 'reason' },
67+
{ matcher: 'other-lib' },
68+
]);
69+
});
70+
71+
it('should not modify rules without projects', () => {
72+
updateNxJson(tree, {
73+
...readNxJson(tree),
74+
conformance: {
75+
rules: [{ rule: './some-rule' }],
76+
},
77+
} as any);
78+
79+
updateOwnersAndConformance(tree, schema);
80+
81+
const nxJson = readNxJson(tree) as any;
82+
expect(nxJson.conformance.rules[0]).toEqual({ rule: './some-rule' });
83+
});
84+
85+
it('should preserve glob patterns and tag references', () => {
86+
updateNxJson(tree, {
87+
...readNxJson(tree),
88+
conformance: {
89+
rules: [
90+
{
91+
rule: './some-rule',
92+
projects: ['my-lib', 'tag:frontend', 'lib-*'],
93+
},
94+
],
95+
},
96+
} as any);
97+
98+
updateOwnersAndConformance(tree, schema);
99+
100+
const nxJson = readNxJson(tree) as any;
101+
expect(nxJson.conformance.rules[0].projects).toEqual([
102+
'my-destination',
103+
'tag:frontend',
104+
'lib-*',
105+
]);
106+
});
107+
108+
it('should handle multiple rules', () => {
109+
updateNxJson(tree, {
110+
...readNxJson(tree),
111+
conformance: {
112+
rules: [
113+
{ rule: './rule-a', projects: ['my-lib'] },
114+
{ rule: './rule-b', projects: ['other-lib'] },
115+
{ rule: './rule-c', projects: ['my-lib', 'other-lib'] },
116+
],
117+
},
118+
} as any);
119+
120+
updateOwnersAndConformance(tree, schema);
121+
122+
const nxJson = readNxJson(tree) as any;
123+
expect(nxJson.conformance.rules[0].projects).toEqual(['my-destination']);
124+
expect(nxJson.conformance.rules[1].projects).toEqual(['other-lib']);
125+
expect(nxJson.conformance.rules[2].projects).toEqual([
126+
'my-destination',
127+
'other-lib',
128+
]);
129+
});
130+
131+
it('should not modify nxJson when no conformance config exists', () => {
132+
const originalNxJson = readNxJson(tree);
133+
134+
updateOwnersAndConformance(tree, schema);
135+
136+
expect(readNxJson(tree)).toEqual(originalNxJson);
137+
});
138+
});
139+
140+
describe('owners patterns', () => {
141+
it('should rename project in owners pattern projects', () => {
142+
updateNxJson(tree, {
143+
...readNxJson(tree),
144+
owners: {
145+
patterns: [
146+
{ projects: ['my-lib', 'other-lib'], owners: ['@team-a'] },
147+
],
148+
},
149+
} as any);
150+
151+
updateOwnersAndConformance(tree, schema);
152+
153+
const nxJson = readNxJson(tree) as any;
154+
expect(nxJson.owners.patterns[0].projects).toEqual([
155+
'my-destination',
156+
'other-lib',
157+
]);
158+
});
159+
160+
it('should rename project in section-level patterns', () => {
161+
updateNxJson(tree, {
162+
...readNxJson(tree),
163+
owners: {
164+
sections: [
165+
{
166+
name: 'Core',
167+
defaultOwners: ['@core-team'],
168+
patterns: [
169+
{ projects: ['my-lib'], owners: ['@team-a'] },
170+
{ projects: ['other-lib'], owners: ['@team-b'] },
171+
],
172+
},
173+
],
174+
},
175+
} as any);
176+
177+
updateOwnersAndConformance(tree, schema);
178+
179+
const nxJson = readNxJson(tree) as any;
180+
expect(nxJson.owners.sections[0].patterns[0].projects).toEqual([
181+
'my-destination',
182+
]);
183+
expect(nxJson.owners.sections[0].patterns[1].projects).toEqual([
184+
'other-lib',
185+
]);
186+
});
187+
188+
it('should not modify patterns without projects', () => {
189+
updateNxJson(tree, {
190+
...readNxJson(tree),
191+
owners: {
192+
patterns: [{ owners: ['@team-a'] }],
193+
},
194+
} as any);
195+
196+
updateOwnersAndConformance(tree, schema);
197+
198+
const nxJson = readNxJson(tree) as any;
199+
expect(nxJson.owners.patterns[0]).toEqual({ owners: ['@team-a'] });
200+
});
201+
202+
it('should preserve glob patterns and tag references', () => {
203+
updateNxJson(tree, {
204+
...readNxJson(tree),
205+
owners: {
206+
patterns: [
207+
{
208+
projects: ['my-lib', 'tag:frontend', 'lib-*'],
209+
owners: ['@team-a'],
210+
},
211+
],
212+
},
213+
} as any);
214+
215+
updateOwnersAndConformance(tree, schema);
216+
217+
const nxJson = readNxJson(tree) as any;
218+
expect(nxJson.owners.patterns[0].projects).toEqual([
219+
'my-destination',
220+
'tag:frontend',
221+
'lib-*',
222+
]);
223+
});
224+
225+
it('should handle owners set to true', () => {
226+
updateNxJson(tree, {
227+
...readNxJson(tree),
228+
owners: true,
229+
} as any);
230+
231+
updateOwnersAndConformance(tree, schema);
232+
233+
const nxJson = readNxJson(tree) as any;
234+
expect(nxJson.owners).toBe(true);
235+
});
236+
237+
it('should handle both top-level and section patterns', () => {
238+
updateNxJson(tree, {
239+
...readNxJson(tree),
240+
owners: {
241+
patterns: [{ projects: ['my-lib'], owners: ['@team-a'] }],
242+
sections: [
243+
{
244+
name: 'Section',
245+
defaultOwners: ['@default'],
246+
patterns: [{ projects: ['my-lib'], owners: ['@team-b'] }],
247+
},
248+
],
249+
},
250+
} as any);
251+
252+
updateOwnersAndConformance(tree, schema);
253+
254+
const nxJson = readNxJson(tree) as any;
255+
expect(nxJson.owners.patterns[0].projects).toEqual(['my-destination']);
256+
expect(nxJson.owners.sections[0].patterns[0].projects).toEqual([
257+
'my-destination',
258+
]);
259+
});
260+
});
261+
});

0 commit comments

Comments
 (0)