Skip to content

Commit 1ccce2c

Browse files
committed
feat(nx-infra-plugin): migrate verify:licenses to nx executor
1 parent 635cab6 commit 1ccce2c

9 files changed

Lines changed: 232 additions & 30 deletions

File tree

packages/devextreme/build/gulp/check_licenses.js

Lines changed: 0 additions & 26 deletions
This file was deleted.

packages/devextreme/gulpfile.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ require('./build/gulp/transpile');
3333
require('./build/gulp/js-bundles');
3434
require('./build/gulp/ts');
3535
require('./build/gulp/localization');
36-
require('./build/gulp/check_licenses');
3736
require('./build/gulp/systemjs');
3837

3938
function getTranspileConfig() {
@@ -70,6 +69,8 @@ gulp.task('aspnet', shell.task(
7069

7170
gulp.task('vendor', shell.task('pnpm nx run devextreme:copy:vendor'));
7271

72+
gulp.task('check-license-notices', shell.task('pnpm nx run devextreme:verify:licenses'));
73+
7374
gulp.task('state-manager-optimize', shell.task('pnpm nx run devextreme:state-manager:optimize'));
7475

7576
gulp.task('npm', shell.task(

packages/devextreme/project.json

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -957,10 +957,23 @@
957957
]
958958
},
959959
"verify:licenses": {
960-
"executor": "nx:run-commands",
960+
"executor": "devextreme-nx-infra-plugin:license-check",
961+
"inputs": [
962+
"{projectRoot}/artifacts/js/dx.all.js"
963+
],
961964
"options": {
962-
"command": "gulp check-license-notices",
963-
"cwd": "{projectRoot}"
965+
"files": [
966+
"./artifacts/js/dx.all.js"
967+
],
968+
"licenses": [
969+
{
970+
"name": "rrule.js - Library for working with recurrence rules for calendar dates.",
971+
"homepageUrl": "https://github.com/jakubroztocil/rrule",
972+
"copyright": "Copyright 2010, Jakub Roztocil and Lars Schoning",
973+
"licenseType": "Licenced under the BSD licence.",
974+
"licenseUrl": "https://github.com/jakubroztocil/rrule/blob/master/LICENCE"
975+
}
976+
]
964977
}
965978
},
966979
"copy:vendor:js": {

packages/nx-infra-plugin/executors.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@
110110
"schema": "./src/executors/scss-assemble/schema.json",
111111
"description": "Assemble SCSS package: copy files with data-uri inlining, fonts, and icons"
112112
},
113+
"license-check": {
114+
"implementation": "./src/executors/license-check/executor",
115+
"schema": "./src/executors/license-check/schema.json",
116+
"description": "Verify embedded license notices in built artifacts"
117+
},
113118
"state-manager-optimize": {
114119
"implementation": "./src/executors/state-manager-optimize/executor",
115120
"schema": "./src/executors/state-manager-optimize/schema.json",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { logger } from '@nx/devkit';
4+
import executor from './executor';
5+
import { LicenseCheckExecutorSchema } from './schema';
6+
import { createTempDir, cleanupTempDir, createMockContext } from '../../utils/test-utils';
7+
import { writeFileText } from '../../utils';
8+
9+
const TEST_NOTICE = `/* !
10+
* synthetic-lib.js - Library for verifying the license-check executor.
11+
* https://example.com/synthetic-lib
12+
*
13+
* Copyright 2026, Synthetic Author
14+
* Licensed under the Test license.
15+
* https://example.com/synthetic-lib/LICENSE
16+
*
17+
*/
18+
`;
19+
20+
const TEST_LICENSE: LicenseCheckExecutorSchema['licenses'][number] = {
21+
name: 'synthetic-lib.js - Library for verifying the license-check executor.',
22+
homepageUrl: 'https://example.com/synthetic-lib',
23+
copyright: 'Copyright 2026, Synthetic Author',
24+
licenseType: 'Licensed under the Test license.',
25+
licenseUrl: 'https://example.com/synthetic-lib/LICENSE',
26+
};
27+
28+
describe('LicenseCheckExecutor E2E', () => {
29+
let tempDir: string;
30+
let context = createMockContext();
31+
let projectDir: string;
32+
let errorSpy: jest.SpyInstance;
33+
34+
beforeEach(() => {
35+
tempDir = createTempDir('nx-license-check-e2e-');
36+
context = createMockContext({ root: tempDir });
37+
projectDir = path.join(tempDir, 'packages', 'test-lib');
38+
fs.mkdirSync(projectDir, { recursive: true });
39+
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
40+
});
41+
42+
afterEach(() => {
43+
errorSpy.mockRestore();
44+
cleanupTempDir(tempDir);
45+
});
46+
47+
it('should succeed when configured license notices are present in the bundle', async () => {
48+
const filePath = path.join(projectDir, 'bundle.js');
49+
await writeFileText(filePath, `// prologue\n${TEST_NOTICE}// epilogue\n`);
50+
51+
const result = await executor({ files: ['./bundle.js'], licenses: [TEST_LICENSE] }, context);
52+
53+
expect(result.success).toBe(true);
54+
});
55+
56+
it('should fail and report the missing license name when a notice is absent', async () => {
57+
const filePath = path.join(projectDir, 'bundle.js');
58+
await writeFileText(filePath, '// no notice here\n');
59+
60+
const result = await executor({ files: ['./bundle.js'], licenses: [TEST_LICENSE] }, context);
61+
62+
expect(result.success).toBe(false);
63+
const reportedMessage = String(errorSpy.mock.calls[0][0]);
64+
expect(reportedMessage).toContain(TEST_LICENSE.name);
65+
expect(reportedMessage).toContain('bundle.js');
66+
});
67+
68+
it('should aggregate misses across multiple files and only list failing files', async () => {
69+
const passingPath = path.join(projectDir, 'bundle-a.js');
70+
const failingPath = path.join(projectDir, 'bundle-b.js');
71+
await writeFileText(passingPath, `${TEST_NOTICE}`);
72+
await writeFileText(failingPath, '// missing notice\n');
73+
74+
const result = await executor(
75+
{ files: ['./bundle-a.js', './bundle-b.js'], licenses: [TEST_LICENSE] },
76+
context,
77+
);
78+
79+
expect(result.success).toBe(false);
80+
const reportedMessage = String(errorSpy.mock.calls[0][0]);
81+
expect(reportedMessage).toContain('bundle-b.js');
82+
expect(reportedMessage).not.toContain('bundle-a.js');
83+
});
84+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './license-check.impl';
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as path from 'path';
2+
import { logger } from '@nx/devkit';
3+
import { createExecutor } from '../../utils/create-executor';
4+
import { readFileText } from '../../utils/file-operations';
5+
import { LicenseCheckExecutorSchema, LicenseEntry } from './schema';
6+
7+
interface LicenseMiss {
8+
file: string;
9+
licenseName: string;
10+
}
11+
12+
interface ResolvedLicenseCheck {
13+
projectRoot: string;
14+
files: { absolutePath: string; displayPath: string }[];
15+
licenses: LicenseEntry[];
16+
}
17+
18+
const LICENSE_FIELD_SEPARATOR = '\\s*\\*\\s';
19+
const ERROR_HEADER_SINGLE = 'issue';
20+
const ERROR_HEADER_PLURAL = 'issues';
21+
22+
export function buildLicenseRegex(entry: LicenseEntry): RegExp {
23+
const pattern =
24+
`\\* !\\s*.*${entry.name}${LICENSE_FIELD_SEPARATOR}`
25+
+ `${entry.homepageUrl}${LICENSE_FIELD_SEPARATOR}*\\*\\s`
26+
+ `${entry.copyright}${LICENSE_FIELD_SEPARATOR}`
27+
+ `${entry.licenseType}${LICENSE_FIELD_SEPARATOR}`
28+
+ `${entry.licenseUrl}`;
29+
return new RegExp(pattern);
30+
}
31+
32+
function buildErrorMessage(misses: LicenseMiss[]): string {
33+
const label = misses.length === 1 ? ERROR_HEADER_SINGLE : ERROR_HEADER_PLURAL;
34+
const header = `License notice check failed (${misses.length} ${label}):`;
35+
const bullets = misses
36+
.map((miss) => ` - "${miss.licenseName}" not found in ${miss.file}`)
37+
.join('\n');
38+
return `${header}\n${bullets}`;
39+
}
40+
41+
export default createExecutor<LicenseCheckExecutorSchema, ResolvedLicenseCheck>({
42+
name: 'LicenseCheck',
43+
resolve: (options, { projectRoot }) => {
44+
const files = options.files.map((fileEntry) => {
45+
const absolutePath = path.resolve(projectRoot, fileEntry);
46+
const displayPath = path.relative(projectRoot, absolutePath) || absolutePath;
47+
return { absolutePath, displayPath };
48+
});
49+
return { projectRoot, files, licenses: options.licenses };
50+
},
51+
run: async (resolved) => {
52+
const misses: LicenseMiss[] = [];
53+
for (const fileEntry of resolved.files) {
54+
const fileContent = await readFileText(fileEntry.absolutePath);
55+
for (const licenseEntry of resolved.licenses) {
56+
const licenseRegex = buildLicenseRegex(licenseEntry);
57+
if (fileContent.search(licenseRegex) === -1) {
58+
misses.push({ file: fileEntry.displayPath, licenseName: licenseEntry.name });
59+
}
60+
}
61+
logger.verbose(`Checked license notices in ${fileEntry.displayPath}`);
62+
}
63+
if (misses.length > 0) {
64+
throw new Error(buildErrorMessage(misses));
65+
}
66+
},
67+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"$schema": "https://json-schema.org/schema",
3+
"type": "object",
4+
"properties": {
5+
"files": {
6+
"type": "array",
7+
"items": { "type": "string" },
8+
"minItems": 1,
9+
"description": "Paths to files whose embedded license notices must be verified (relative to project root)"
10+
},
11+
"licenses": {
12+
"type": "array",
13+
"minItems": 1,
14+
"description": "License entries to verify; each entry is converted to a regex and searched in every file",
15+
"items": {
16+
"type": "object",
17+
"properties": {
18+
"name": {
19+
"type": "string",
20+
"description": "Human-readable library description; appears in error messages and is matched verbatim in the bundle"
21+
},
22+
"homepageUrl": {
23+
"type": "string",
24+
"description": "Library homepage URL embedded in the license notice"
25+
},
26+
"copyright": {
27+
"type": "string",
28+
"description": "Copyright line embedded in the license notice"
29+
},
30+
"licenseType": {
31+
"type": "string",
32+
"description": "License-type line embedded in the license notice"
33+
},
34+
"licenseUrl": {
35+
"type": "string",
36+
"description": "License-text URL embedded in the license notice"
37+
}
38+
},
39+
"required": ["name", "homepageUrl", "copyright", "licenseType", "licenseUrl"],
40+
"additionalProperties": false
41+
}
42+
}
43+
},
44+
"required": ["files", "licenses"]
45+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export interface LicenseEntry {
2+
name: string;
3+
homepageUrl: string;
4+
copyright: string;
5+
licenseType: string;
6+
licenseUrl: string;
7+
}
8+
9+
export interface LicenseCheckExecutorSchema {
10+
files: string[];
11+
licenses: LicenseEntry[];
12+
}

0 commit comments

Comments
 (0)