@ovh-ux/manager-static-analysis-kit 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +59 -69
- package/bin/cli-path-config.js +31 -0
- package/bin/code-duplication-cli.js +96 -0
- package/bin/code-duplication-tests-cli.js +68 -0
- package/bin/lint-cli.js +1 -1
- package/bin/perf-budgets-cli.js +58 -297
- package/bin/perf-budgets-tests-cli.js +1 -3
- package/bin/static-dynamic-quality-checks.js +138 -0
- package/bin/tests-coverage-cli-tests.js +67 -0
- package/bin/tests-coverage-cli.js +135 -0
- package/bin/types-coverage-cli.js +127 -0
- package/bin/types-coverage-tests-cli.js +68 -0
- package/bin/utils/apps-utils.js +64 -0
- package/bin/utils/args-parse-utils.js +222 -0
- package/bin/utils/file-utils.js +52 -0
- package/bin/utils/log-utils.js +29 -0
- package/bin/utils/runner-utils.js +85 -0
- package/bin/utils/turbo-utils.js +139 -0
- package/dist/adapters/code-duplication/helpers/code-duplication-analysis-helper.d.ts +6 -0
- package/dist/adapters/code-duplication/helpers/code-duplication-analysis-helper.d.ts.map +1 -0
- package/dist/adapters/code-duplication/helpers/code-duplication-analysis-helper.js +240 -0
- package/dist/adapters/code-duplication/types/CodeDuplication.d.ts +60 -0
- package/dist/adapters/code-duplication/types/CodeDuplication.d.ts.map +1 -0
- package/dist/adapters/code-duplication/types/CodeDuplication.js +1 -0
- package/dist/adapters/eslint/rules/eslint-prettier.js +1 -1
- package/dist/adapters/html-a11y-validation/helpers/html-a11y-tests-setup.d.ts +62 -0
- package/dist/adapters/html-a11y-validation/helpers/html-a11y-tests-setup.d.ts.map +1 -0
- package/dist/adapters/html-a11y-validation/helpers/html-a11y-tests-setup.js +73 -0
- package/dist/adapters/html-w3c-validation/helpers/html-w3c-tests-setup.d.ts +19 -0
- package/dist/adapters/html-w3c-validation/helpers/html-w3c-tests-setup.d.ts.map +1 -0
- package/dist/adapters/html-w3c-validation/helpers/html-w3c-tests-setup.js +32 -0
- package/dist/adapters/html-w3c-validation/helpers/html-w3c-validator.d.ts +4 -0
- package/dist/adapters/html-w3c-validation/helpers/html-w3c-validator.d.ts.map +1 -0
- package/dist/adapters/html-w3c-validation/helpers/html-w3c-validator.js +89 -0
- package/dist/adapters/html-w3c-validation/types/HtmlW3CValidator.d.ts +6 -0
- package/dist/adapters/html-w3c-validation/types/HtmlW3CValidator.d.ts.map +1 -0
- package/dist/adapters/html-w3c-validation/types/HtmlW3CValidator.js +1 -0
- package/dist/adapters/html-w3c-validation/types/globals/vnu-jar.d.ts +5 -0
- package/dist/adapters/perf-budgets/helpers/perfs-bundle-analysis-helper.d.ts +1 -1
- package/dist/adapters/perf-budgets/helpers/perfs-bundle-analysis-helper.d.ts.map +1 -1
- package/dist/adapters/perf-budgets/helpers/perfs-bundle-analysis-helper.js +2 -3
- package/dist/adapters/tests-coverage/helpers/tests-coverage-analysis-helper.d.ts +4 -0
- package/dist/adapters/tests-coverage/helpers/tests-coverage-analysis-helper.d.ts.map +1 -0
- package/dist/adapters/tests-coverage/helpers/tests-coverage-analysis-helper.js +192 -0
- package/dist/adapters/tests-coverage/types/TestsCoverage.d.ts +36 -0
- package/dist/adapters/tests-coverage/types/TestsCoverage.d.ts.map +1 -0
- package/dist/adapters/tests-coverage/types/TestsCoverage.js +6 -0
- package/dist/adapters/types-coverage/helpers/types-coverage-analysis-helper.d.ts +4 -0
- package/dist/adapters/types-coverage/helpers/types-coverage-analysis-helper.d.ts.map +1 -0
- package/dist/adapters/types-coverage/helpers/types-coverage-analysis-helper.js +158 -0
- package/dist/adapters/types-coverage/types/TypesCoverage.d.ts +30 -0
- package/dist/adapters/types-coverage/types/TypesCoverage.d.ts.map +1 -0
- package/dist/adapters/types-coverage/types/TypesCoverage.js +1 -0
- package/dist/configs/architecture-analysis-config.d.ts +5 -0
- package/dist/configs/architecture-analysis-config.d.ts.map +1 -0
- package/dist/configs/{madge-config.js → architecture-analysis-config.js} +1 -1
- package/dist/configs/code-duplication-config.d.ts +21 -0
- package/dist/configs/code-duplication-config.d.ts.map +1 -0
- package/dist/configs/code-duplication-config.js +21 -0
- package/dist/configs/{prettier-config.d.ts → code-formatter-config.d.ts} +1 -1
- package/dist/configs/code-formatter-config.d.ts.map +1 -0
- package/dist/configs/file-globs-config.d.ts +0 -3
- package/dist/configs/file-globs-config.d.ts.map +1 -1
- package/dist/configs/file-globs-config.js +0 -3
- package/dist/configs/naming-conventions-config.d.ts.map +1 -1
- package/dist/configs/naming-conventions-config.js +2 -0
- package/dist/configs/shared-exclusion-patterns.d.ts.map +1 -1
- package/dist/configs/shared-exclusion-patterns.js +3 -1
- package/dist/configs/tests-coverage-config.d.ts +12 -0
- package/dist/configs/tests-coverage-config.d.ts.map +1 -0
- package/dist/configs/tests-coverage-config.js +11 -0
- package/dist/configs/types-coverage-config.d.ts +19 -0
- package/dist/configs/types-coverage-config.d.ts.map +1 -0
- package/dist/configs/types-coverage-config.js +27 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +32 -7
- package/bin/cli-utils.js +0 -56
- package/dist/configs/jscpd-config.d.ts +0 -7
- package/dist/configs/jscpd-config.d.ts.map +0 -1
- package/dist/configs/jscpd-config.js +0 -7
- package/dist/configs/madge-config.d.ts +0 -5
- package/dist/configs/madge-config.d.ts.map +0 -1
- package/dist/configs/prettier-config.d.ts.map +0 -1
- /package/dist/configs/{prettier-config.js → code-formatter-config.js} +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
|
|
6
|
+
import { resolveAppInfo } from './apps-utils.js';
|
|
7
|
+
import { generateCombinedReports } from './file-utils.js';
|
|
8
|
+
import { logError, logInfo } from './log-utils.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ensure the given binary exists.
|
|
12
|
+
*/
|
|
13
|
+
export function ensureBinExists(binPath, label) {
|
|
14
|
+
if (!fs.existsSync(binPath)) {
|
|
15
|
+
logError(`❌ ${label} binary not found at ${binPath}`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run a child process with consistent defaults.
|
|
22
|
+
*/
|
|
23
|
+
export function runCommand(bin, args, cwd) {
|
|
24
|
+
const result = spawnSync(bin, args, {
|
|
25
|
+
cwd,
|
|
26
|
+
stdio: 'inherit',
|
|
27
|
+
shell: true,
|
|
28
|
+
});
|
|
29
|
+
return result.status === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generic per-app analysis runner with safety.
|
|
34
|
+
*/
|
|
35
|
+
export function runAppsAnalysis({
|
|
36
|
+
appsDir,
|
|
37
|
+
appFolders,
|
|
38
|
+
requireReact = false,
|
|
39
|
+
binaryLabel,
|
|
40
|
+
appRunner, // (appDir, appShortName, appInfo) => boolean
|
|
41
|
+
reportsRootDirName,
|
|
42
|
+
combinedJson,
|
|
43
|
+
combinedHtml,
|
|
44
|
+
collectFn,
|
|
45
|
+
generateHtmlFn,
|
|
46
|
+
outputRootDir,
|
|
47
|
+
}) {
|
|
48
|
+
const analyzed = [];
|
|
49
|
+
|
|
50
|
+
for (const appFolder of appFolders) {
|
|
51
|
+
const appInfo = resolveAppInfo(appsDir, appFolder, { requireReact });
|
|
52
|
+
if (!appInfo) continue;
|
|
53
|
+
|
|
54
|
+
let ok = false;
|
|
55
|
+
try {
|
|
56
|
+
ok = appRunner(appInfo.appDir, appInfo.appShortName, appInfo);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logError(
|
|
59
|
+
`❌ Fatal error while analyzing ${appInfo.appShortName}: ${err.stack || err.message}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (ok) analyzed.push(appInfo.appShortName);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (analyzed.length > 0) {
|
|
66
|
+
if (typeof collectFn !== 'function' || typeof generateHtmlFn !== 'function') {
|
|
67
|
+
logError(`❌ Invalid collector or HTML generator passed to ${binaryLabel} analysis`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
generateCombinedReports(
|
|
72
|
+
path.join(outputRootDir, reportsRootDirName),
|
|
73
|
+
combinedJson,
|
|
74
|
+
combinedHtml,
|
|
75
|
+
collectFn,
|
|
76
|
+
generateHtmlFn,
|
|
77
|
+
);
|
|
78
|
+
logInfo(`✅ ${binaryLabel} reports generated (per-app + combined).`);
|
|
79
|
+
} else {
|
|
80
|
+
logError('❌ No apps successfully analyzed. Exiting.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return analyzed;
|
|
85
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { logError, logInfo, logWarn } from './log-utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert app folder names into Turbo filters by resolving their package.json names.
|
|
9
|
+
*
|
|
10
|
+
* Each app folder must contain a valid `package.json`. If not found, the app is skipped.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} appsDir - Absolute path to the apps root directory.
|
|
13
|
+
* @param {string[]} appFolders - List of app folder names inside the apps directory.
|
|
14
|
+
* @returns {string[]} Array of package names to be used as Turbo filters.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // appsDir = "/workspace/manager/apps"
|
|
18
|
+
* // appFolders = ["zimbra", "web"]
|
|
19
|
+
* // Returns: ["@ovh-ux/manager-zimbra-app", "@ovh-ux/manager-web-app"]
|
|
20
|
+
*/
|
|
21
|
+
function resolveTurboFiltersFromAppFolders(appsDir, appFolders) {
|
|
22
|
+
return appFolders
|
|
23
|
+
.map((appFolder) => {
|
|
24
|
+
const pkgPath = path.join(appsDir, appFolder, 'package.json');
|
|
25
|
+
if (!fs.existsSync(pkgPath)) {
|
|
26
|
+
logError(`❌ No package.json for app folder ${appFolder}, skipping`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
30
|
+
return pkg.name;
|
|
31
|
+
})
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve Turbo filters (package names) based on input mode.
|
|
37
|
+
*
|
|
38
|
+
* - If only `appFolders` are provided, resolves their corresponding package names.
|
|
39
|
+
* - If `packageNames` are provided, they are used directly as filters.
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} options
|
|
42
|
+
* @param {string} options.appsDir - Absolute path to the apps root directory.
|
|
43
|
+
* @param {string[]} options.appFolders - List of app folders to analyze.
|
|
44
|
+
* @param {string[]} options.packageNames - List of package names (overrides appFolders).
|
|
45
|
+
* @returns {string[]} List of resolved Turbo filters (package names).
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* resolveTurboFilters({
|
|
49
|
+
* appsDir: "/workspace/manager/apps",
|
|
50
|
+
* appFolders: ["zimbra"],
|
|
51
|
+
* packageNames: []
|
|
52
|
+
* });
|
|
53
|
+
* // → ["@ovh-ux/manager-zimbra-app"]
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* resolveTurboFilters({
|
|
57
|
+
* appsDir: "/workspace/manager/apps",
|
|
58
|
+
* appFolders: [],
|
|
59
|
+
* packageNames: ["@ovh-ux/manager-web-app"]
|
|
60
|
+
* });
|
|
61
|
+
* // → ["@ovh-ux/manager-web-app"]
|
|
62
|
+
*/
|
|
63
|
+
export function resolveTurboFilters({ appsDir, appFolders, packageNames }) {
|
|
64
|
+
let filters = [];
|
|
65
|
+
|
|
66
|
+
if (appFolders.length > 0 && packageNames.length === 0) {
|
|
67
|
+
filters = resolveTurboFiltersFromAppFolders(appsDir, appFolders);
|
|
68
|
+
} else if (packageNames.length > 0) {
|
|
69
|
+
filters = packageNames;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return filters;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run a Turbo build for the provided filters.
|
|
77
|
+
*
|
|
78
|
+
* - Executes `turbo run build` with `--filter` for each package name.
|
|
79
|
+
* - Runs in the specified root directory.
|
|
80
|
+
* - If no filters are provided, the build step is skipped with a warning.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} rootDir - Root directory where Turbo should be executed.
|
|
83
|
+
* @param {string[]} filters - List of Turbo build filters (package names).
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* runTurboBuild("/workspace", ["@ovh-ux/manager-zimbra-app"]);
|
|
88
|
+
* // Executes: turbo run build --filter @ovh-ux/manager-zimbra-app
|
|
89
|
+
*/
|
|
90
|
+
export function runTurboBuild(rootDir, filters) {
|
|
91
|
+
if (filters.length === 0) {
|
|
92
|
+
logWarn('⚠️ No Turbo build filters provided, skipping build step.');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const turboArgs = ['run', 'build', ...filters.flatMap((pkgName) => ['--filter', pkgName])];
|
|
97
|
+
logInfo(`Building with Turbo (filters: ${filters.join(', ')})`);
|
|
98
|
+
|
|
99
|
+
const buildResult = spawnSync('turbo', turboArgs, {
|
|
100
|
+
cwd: rootDir,
|
|
101
|
+
stdio: 'inherit',
|
|
102
|
+
shell: true,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (buildResult.status !== 0) {
|
|
106
|
+
logWarn('⚠️ Turbo build failed for one or more targets. Will still try to analyze apps.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run Turbo tests with optional filters and extra arguments.
|
|
112
|
+
*
|
|
113
|
+
* This function spawns a synchronous Turbo process to run tests,
|
|
114
|
+
* applying filters for specific packages if provided. It logs
|
|
115
|
+
* progress and warns if any tests fail, but does not throw.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} rootDir - Root directory to run Turbo in (typically the monorepo root).
|
|
118
|
+
* @param {string[]} filters - List of package filters (Turbo `--filter` flags).
|
|
119
|
+
* @param {string[]} [extraArgs=[]] - Additional arguments to forward to Turbo.
|
|
120
|
+
* @returns {void}
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* // Run tests for "web" and "zimbra" apps with coverage
|
|
124
|
+
* runTurboTests(process.cwd(), ['web', 'zimbra'], ['--', '--coverage']);
|
|
125
|
+
*/
|
|
126
|
+
export function runTurboTests(rootDir, filters, extraArgs = []) {
|
|
127
|
+
const args = ['run', 'test', ...filters.flatMap((pkg) => ['--filter', pkg]), ...extraArgs];
|
|
128
|
+
logInfo(`Testing with Turbo (filters: ${filters.join(', ')})`);
|
|
129
|
+
|
|
130
|
+
const res = spawnSync('turbo', args, {
|
|
131
|
+
cwd: rootDir,
|
|
132
|
+
stdio: 'inherit',
|
|
133
|
+
shell: true,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (res.status !== 0) {
|
|
137
|
+
logWarn('⚠️ Turbo tests failed for one or more targets. Will still harvest coverage if any.');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { CombinedCodeDuplicationSummary, JscpdReportV4, WorstCodeDuplicationFile } from '../types/CodeDuplication.js';
|
|
2
|
+
export declare const extPriority: (file: string) => number;
|
|
3
|
+
export declare function extractWorstFiles(report: JscpdReportV4): WorstCodeDuplicationFile[];
|
|
4
|
+
export declare function collectCodeDuplication(reportOutputDir: string): CombinedCodeDuplicationSummary;
|
|
5
|
+
export declare function generateCodeDuplicationHtml(summary: CombinedCodeDuplicationSummary): string;
|
|
6
|
+
//# sourceMappingURL=code-duplication-analysis-helper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"code-duplication-analysis-helper.d.ts","sourceRoot":"","sources":["../../../../src/adapters/code-duplication/helpers/code-duplication-analysis-helper.ts"],"names":[],"mappings":"AAIA,OAAO,EAEL,8BAA8B,EAC9B,aAAa,EACb,wBAAwB,EAEzB,MAAM,0BAA0B,CAAC;AAUlC,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,MAc1C,CAAC;AAkEF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,aAAa,GAAG,wBAAwB,EAAE,CAsEnF;AAkED,wBAAgB,sBAAsB,CAAC,eAAe,EAAE,MAAM,GAAG,8BAA8B,CA+B9F;AAyGD,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,8BAA8B,GAAG,MAAM,CAmC3F"}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { codeDuplicationConfig } from '../../../configs/code-duplication-config.js';
|
|
4
|
+
export const extPriority = (file) => {
|
|
5
|
+
const f = file.toLowerCase();
|
|
6
|
+
if (/\.(d\.ts)$/.test(f))
|
|
7
|
+
return 2;
|
|
8
|
+
if (/\.(tsx?|mts|cts)$/.test(f))
|
|
9
|
+
return 0;
|
|
10
|
+
if (/\.(jsx?|mjs|cjs)$/.test(f))
|
|
11
|
+
return 1;
|
|
12
|
+
return 2;
|
|
13
|
+
};
|
|
14
|
+
const safeCountLines = (file) => {
|
|
15
|
+
try {
|
|
16
|
+
const txt = fs.readFileSync(file, 'utf8');
|
|
17
|
+
return txt.split(/\r?\n/).length;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function shortPathFromAppsRoot(filePath, app) {
|
|
24
|
+
const norm = filePath.replace(/\\/g, '/');
|
|
25
|
+
const appsIdx = norm.indexOf('/apps/');
|
|
26
|
+
if (appsIdx >= 0) {
|
|
27
|
+
return norm.slice(appsIdx + 1);
|
|
28
|
+
}
|
|
29
|
+
const parts = norm.split('/');
|
|
30
|
+
return parts.slice(Math.max(0, parts.length - 5)).join('/');
|
|
31
|
+
}
|
|
32
|
+
export function extractWorstFiles(report) {
|
|
33
|
+
const dups = Array.isArray(report?.duplicates) ? report.duplicates : [];
|
|
34
|
+
if (dups.length === 0)
|
|
35
|
+
return [];
|
|
36
|
+
const { threshold, count } = codeDuplicationConfig.worstFiles;
|
|
37
|
+
const fileMap = {};
|
|
38
|
+
for (const dup of dups) {
|
|
39
|
+
const lines = Number(dup?.lines ?? 0);
|
|
40
|
+
if (!lines)
|
|
41
|
+
continue;
|
|
42
|
+
for (const side of [dup.firstFile, dup.secondFile]) {
|
|
43
|
+
const file = side?.name;
|
|
44
|
+
if (!file)
|
|
45
|
+
continue;
|
|
46
|
+
const endLine = Number(side?.endLoc?.line ?? 0);
|
|
47
|
+
const entry = fileMap[file] ?? { dup: 0, estimatedTotal: 0 };
|
|
48
|
+
entry.dup += lines;
|
|
49
|
+
if (endLine > entry.estimatedTotal)
|
|
50
|
+
entry.estimatedTotal = endLine;
|
|
51
|
+
fileMap[file] = entry;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const file of Object.keys(fileMap)) {
|
|
55
|
+
const realTotal = safeCountLines(file);
|
|
56
|
+
const estTotal = fileMap[file].estimatedTotal;
|
|
57
|
+
fileMap[file].total = Math.max(realTotal || estTotal || 0, fileMap[file].dup);
|
|
58
|
+
}
|
|
59
|
+
return Object.entries(fileMap)
|
|
60
|
+
.map(([file, { dup, total = 0 }]) => {
|
|
61
|
+
const percentage = total > 0 ? (dup / total) * 100 : 0;
|
|
62
|
+
const stats = {
|
|
63
|
+
duplicatedLines: dup,
|
|
64
|
+
totalLines: total,
|
|
65
|
+
percentage,
|
|
66
|
+
};
|
|
67
|
+
return [file, stats];
|
|
68
|
+
})
|
|
69
|
+
.filter(([, s]) => s.percentage >= threshold)
|
|
70
|
+
.sort((a, b) => {
|
|
71
|
+
const pa = extPriority(a[0]);
|
|
72
|
+
const pb = extPriority(b[0]);
|
|
73
|
+
if (pa !== pb)
|
|
74
|
+
return pa - pb;
|
|
75
|
+
const pct = b[1].percentage - a[1].percentage;
|
|
76
|
+
if (pct !== 0)
|
|
77
|
+
return pct;
|
|
78
|
+
if (b[1].duplicatedLines !== a[1].duplicatedLines) {
|
|
79
|
+
return b[1].duplicatedLines - a[1].duplicatedLines;
|
|
80
|
+
}
|
|
81
|
+
if (b[1].totalLines !== a[1].totalLines) {
|
|
82
|
+
return b[1].totalLines - a[1].totalLines;
|
|
83
|
+
}
|
|
84
|
+
return a[0].localeCompare(b[0]);
|
|
85
|
+
})
|
|
86
|
+
.slice(0, count);
|
|
87
|
+
}
|
|
88
|
+
function parseAppCodeDuplicationReport(jsonPath) {
|
|
89
|
+
try {
|
|
90
|
+
const report = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
91
|
+
const total = report.statistics?.total ?? {};
|
|
92
|
+
const totalLines = total.lines ?? 0;
|
|
93
|
+
const duplicateLines = total.duplicatedLines ?? 0;
|
|
94
|
+
return {
|
|
95
|
+
totalLines,
|
|
96
|
+
totalTokens: total.tokens ?? 0,
|
|
97
|
+
duplicateLines,
|
|
98
|
+
duplicateTokens: total.duplicatedTokens ?? 0,
|
|
99
|
+
clonesCount: total.clones ?? 0,
|
|
100
|
+
percentage: total.percentage ?? (totalLines > 0 ? (duplicateLines / totalLines) * 100 : 0),
|
|
101
|
+
worstFiles: extractWorstFiles(report),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
totalLines: 0,
|
|
107
|
+
totalTokens: 0,
|
|
108
|
+
duplicateLines: 0,
|
|
109
|
+
duplicateTokens: 0,
|
|
110
|
+
clonesCount: 0,
|
|
111
|
+
percentage: 0,
|
|
112
|
+
error: err.message,
|
|
113
|
+
worstFiles: [],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function aggregateTotals(apps) {
|
|
118
|
+
return Object.values(apps).reduce((acc, app) => {
|
|
119
|
+
acc.totalLines += app.totalLines;
|
|
120
|
+
acc.totalTokens += app.totalTokens;
|
|
121
|
+
acc.totalDuplicateLines += app.duplicateLines;
|
|
122
|
+
acc.totalDuplicateTokens += app.duplicateTokens;
|
|
123
|
+
return acc;
|
|
124
|
+
}, { totalLines: 0, totalTokens: 0, totalDuplicateLines: 0, totalDuplicateTokens: 0 });
|
|
125
|
+
}
|
|
126
|
+
export function collectCodeDuplication(reportOutputDir) {
|
|
127
|
+
if (!fs.existsSync(reportOutputDir)) {
|
|
128
|
+
return {
|
|
129
|
+
apps: {},
|
|
130
|
+
totalLines: 0,
|
|
131
|
+
totalTokens: 0,
|
|
132
|
+
totalDuplicateLines: 0,
|
|
133
|
+
totalDuplicateTokens: 0,
|
|
134
|
+
percentage: 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const apps = {};
|
|
138
|
+
const appDirs = fs
|
|
139
|
+
.readdirSync(reportOutputDir)
|
|
140
|
+
.filter((entry) => fs.statSync(path.join(reportOutputDir, entry)).isDirectory());
|
|
141
|
+
for (const appDirName of appDirs) {
|
|
142
|
+
const jsonPath = path.join(reportOutputDir, appDirName, 'jscpd-report.json');
|
|
143
|
+
if (fs.existsSync(jsonPath)) {
|
|
144
|
+
apps[appDirName] = parseAppCodeDuplicationReport(jsonPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const totals = aggregateTotals(apps);
|
|
148
|
+
return {
|
|
149
|
+
apps,
|
|
150
|
+
...totals,
|
|
151
|
+
percentage: totals.totalLines > 0 ? (totals.totalDuplicateLines / totals.totalLines) * 100 : 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const getColorForDuplicationStatus = (pct) => {
|
|
155
|
+
const green = codeDuplicationConfig.thresholds?.green ?? 10;
|
|
156
|
+
const orange = codeDuplicationConfig.thresholds?.orange ?? 20;
|
|
157
|
+
return pct < green ? 'green' : pct < orange ? 'orange' : 'red';
|
|
158
|
+
};
|
|
159
|
+
function renderWorstFiles(app, worstFiles = []) {
|
|
160
|
+
if (!worstFiles.length) {
|
|
161
|
+
return `
|
|
162
|
+
<details>
|
|
163
|
+
<summary>⚠️ Worst duplicated files</summary>
|
|
164
|
+
<p style="margin:0.5rem 0; color:#888;">No files above threshold 🎉</p>
|
|
165
|
+
</details>`;
|
|
166
|
+
}
|
|
167
|
+
const rows = worstFiles
|
|
168
|
+
.map(([file, f]) => {
|
|
169
|
+
const short = shortPathFromAppsRoot(file, app);
|
|
170
|
+
const color = getColorForDuplicationStatus(f.percentage);
|
|
171
|
+
return `
|
|
172
|
+
<tr>
|
|
173
|
+
<td colspan="2" title="${file}">${short}</td>
|
|
174
|
+
<td>${f.duplicatedLines}/${f.totalLines}</td>
|
|
175
|
+
<td style="color:${color}">${f.percentage.toFixed(2)}%</td>
|
|
176
|
+
</tr>`;
|
|
177
|
+
})
|
|
178
|
+
.join('');
|
|
179
|
+
return `
|
|
180
|
+
<details>
|
|
181
|
+
<summary>⚠️ Worst duplicated files (≥ ${codeDuplicationConfig.worstFiles.threshold}%)</summary>
|
|
182
|
+
<table style="margin:0.5rem 0; border:1px solid #ccc; width:95%;">
|
|
183
|
+
<tbody>${rows}</tbody>
|
|
184
|
+
</table>
|
|
185
|
+
</details>`;
|
|
186
|
+
}
|
|
187
|
+
function buildAppRow(app, stats) {
|
|
188
|
+
if (stats.error) {
|
|
189
|
+
return `<tr style="background:#ffe0e0">
|
|
190
|
+
<td>${app}</td>
|
|
191
|
+
<td colspan="5" style="color:red">${stats.error}</td>
|
|
192
|
+
</tr>`;
|
|
193
|
+
}
|
|
194
|
+
const color = getColorForDuplicationStatus(stats.percentage);
|
|
195
|
+
const bg = color === 'green' ? '#e6ffed' : color === 'orange' ? '#fff8e1' : '#ffe6e6';
|
|
196
|
+
return `
|
|
197
|
+
<tr style="background:${bg}">
|
|
198
|
+
<td>${app}</td>
|
|
199
|
+
<td>${stats.totalLines}</td>
|
|
200
|
+
<td>${stats.duplicateLines}</td>
|
|
201
|
+
<td>${stats.clonesCount}</td>
|
|
202
|
+
<td style="font-weight:bold; color:${color};">${stats.percentage.toFixed(2)}%</td>
|
|
203
|
+
</tr>
|
|
204
|
+
<tr><td colspan="5">${renderWorstFiles(app, stats.worstFiles)}</td></tr>`;
|
|
205
|
+
}
|
|
206
|
+
export function generateCodeDuplicationHtml(summary) {
|
|
207
|
+
const rows = Object.entries(summary.apps)
|
|
208
|
+
.map(([app, stats]) => buildAppRow(app, stats))
|
|
209
|
+
.join('');
|
|
210
|
+
return `<!DOCTYPE html>
|
|
211
|
+
<html lang="en">
|
|
212
|
+
<head>
|
|
213
|
+
<meta charset="UTF-8"/>
|
|
214
|
+
<title>Code Duplication Report</title>
|
|
215
|
+
<style>
|
|
216
|
+
body { font-family: Arial, sans-serif; margin: 2rem; }
|
|
217
|
+
table { border-collapse: collapse; width: 100%; }
|
|
218
|
+
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; }
|
|
219
|
+
th { background: #f0f0f0; }
|
|
220
|
+
</style>
|
|
221
|
+
</head>
|
|
222
|
+
<body>
|
|
223
|
+
<h1>Code Duplication Report</h1>
|
|
224
|
+
<p>Total lines: ${summary.totalLines}, Duplicate lines: ${summary.totalDuplicateLines},
|
|
225
|
+
Percentage: ${summary.percentage.toFixed(2)}%</p>
|
|
226
|
+
<table>
|
|
227
|
+
<thead>
|
|
228
|
+
<tr>
|
|
229
|
+
<th>App</th>
|
|
230
|
+
<th>Total lines</th>
|
|
231
|
+
<th>Duplicate lines</th>
|
|
232
|
+
<th>Clones</th>
|
|
233
|
+
<th>Duplication %</th>
|
|
234
|
+
</tr>
|
|
235
|
+
</thead>
|
|
236
|
+
<tbody>${rows}</tbody>
|
|
237
|
+
</table>
|
|
238
|
+
</body>
|
|
239
|
+
</html>`;
|
|
240
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface AppCodeDuplicationSummary {
|
|
2
|
+
totalLines: number;
|
|
3
|
+
totalTokens: number;
|
|
4
|
+
duplicateLines: number;
|
|
5
|
+
duplicateTokens: number;
|
|
6
|
+
percentage: number;
|
|
7
|
+
clonesCount: number;
|
|
8
|
+
error?: string;
|
|
9
|
+
worstFiles?: WorstCodeDuplicationFile[];
|
|
10
|
+
}
|
|
11
|
+
export interface CombinedCodeDuplicationSummary {
|
|
12
|
+
apps: Record<string, AppCodeDuplicationSummary>;
|
|
13
|
+
totalLines: number;
|
|
14
|
+
totalTokens: number;
|
|
15
|
+
totalDuplicateLines: number;
|
|
16
|
+
totalDuplicateTokens: number;
|
|
17
|
+
percentage: number;
|
|
18
|
+
}
|
|
19
|
+
export type WorstCodeDuplicationFile = [
|
|
20
|
+
string,
|
|
21
|
+
{
|
|
22
|
+
duplicatedLines: number;
|
|
23
|
+
totalLines: number;
|
|
24
|
+
percentage: number;
|
|
25
|
+
}
|
|
26
|
+
];
|
|
27
|
+
export interface WorstFileStats {
|
|
28
|
+
duplicatedLines: number;
|
|
29
|
+
totalLines: number;
|
|
30
|
+
percentage: number;
|
|
31
|
+
}
|
|
32
|
+
interface JscpdDuplicateSide {
|
|
33
|
+
name: string;
|
|
34
|
+
startLoc?: {
|
|
35
|
+
line?: number;
|
|
36
|
+
};
|
|
37
|
+
endLoc?: {
|
|
38
|
+
line?: number;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
interface JscpdDuplicate {
|
|
42
|
+
lines: number;
|
|
43
|
+
firstFile: JscpdDuplicateSide;
|
|
44
|
+
secondFile: JscpdDuplicateSide;
|
|
45
|
+
}
|
|
46
|
+
export interface JscpdReportV4 {
|
|
47
|
+
statistics?: {
|
|
48
|
+
total?: {
|
|
49
|
+
lines?: number;
|
|
50
|
+
tokens?: number;
|
|
51
|
+
duplicatedLines?: number;
|
|
52
|
+
duplicatedTokens?: number;
|
|
53
|
+
clones?: number;
|
|
54
|
+
percentage?: number;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
duplicates?: JscpdDuplicate[];
|
|
58
|
+
}
|
|
59
|
+
export {};
|
|
60
|
+
//# sourceMappingURL=CodeDuplication.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CodeDuplication.d.ts","sourceRoot":"","sources":["../../../../src/adapters/code-duplication/types/CodeDuplication.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,yBAAyB;IAExC,UAAU,EAAE,MAAM,CAAC;IAGnB,WAAW,EAAE,MAAM,CAAC;IAGpB,cAAc,EAAE,MAAM,CAAC;IAGvB,eAAe,EAAE,MAAM,CAAC;IAMxB,UAAU,EAAE,MAAM,CAAC;IAMnB,WAAW,EAAE,MAAM,CAAC;IAMpB,KAAK,CAAC,EAAE,MAAM,CAAC;IAMf,UAAU,CAAC,EAAE,wBAAwB,EAAE,CAAC;CACzC;AAKD,MAAM,WAAW,8BAA8B;IAK7C,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAC;IAGhD,UAAU,EAAE,MAAM,CAAC;IAGnB,WAAW,EAAE,MAAM,CAAC;IAGpB,mBAAmB,EAAE,MAAM,CAAC;IAG5B,oBAAoB,EAAE,MAAM,CAAC;IAM7B,UAAU,EAAE,MAAM,CAAC;CACpB;AAcD,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM;IACN;QAEE,eAAe,EAAE,MAAM,CAAC;QAGxB,UAAU,EAAE,MAAM,CAAC;QAGnB,UAAU,EAAE,MAAM,CAAC;KACpB;CACF,CAAC;AAKF,MAAM,WAAW,cAAc;IAE7B,eAAe,EAAE,MAAM,CAAC;IAGxB,UAAU,EAAE,MAAM,CAAC;IAGnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAKD,UAAU,kBAAkB;IAE1B,IAAI,EAAE,MAAM,CAAC;IAMb,QAAQ,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAM7B,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC5B;AAKD,UAAU,cAAc;IAKtB,KAAK,EAAE,MAAM,CAAC;IAGd,SAAS,EAAE,kBAAkB,CAAC;IAG9B,UAAU,EAAE,kBAAkB,CAAC;CAChC;AAMD,MAAM,WAAW,aAAa;IAK5B,UAAU,CAAC,EAAE;QACX,KAAK,CAAC,EAAE;YAEN,KAAK,CAAC,EAAE,MAAM,CAAC;YAGf,MAAM,CAAC,EAAE,MAAM,CAAC;YAGhB,eAAe,CAAC,EAAE,MAAM,CAAC;YAGzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;YAG1B,MAAM,CAAC,EAAE,MAAM,CAAC;YAMhB,UAAU,CAAC,EAAE,MAAM,CAAC;SACrB,CAAC;KACH,CAAC;IAMF,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;CAC/B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import prettier from 'eslint-plugin-prettier';
|
|
2
|
+
import { prettierConfig, prettierIgnorePatterns } from '../../../configs/code-formatter-config.js';
|
|
2
3
|
import { commonTextFiles } from '../../../configs/file-globs-config.js';
|
|
3
|
-
import { prettierConfig, prettierIgnorePatterns } from '../../../configs/prettier-config.js';
|
|
4
4
|
export const prettierEslintConfig = {
|
|
5
5
|
files: [commonTextFiles],
|
|
6
6
|
ignores: prettierIgnorePatterns,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview
|
|
3
|
+
* Ambient module augmentation for the custom Vitest matcher `toBeAccessible`.
|
|
4
|
+
* Import the setup file
|
|
5
|
+
* `@ovh-ux/manager-static-analysis-kit/tests/html-a11y-tests-setup`
|
|
6
|
+
* in your test setup to register the matcher at runtime and include these types.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import 'vitest';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Options object accepted by the accessibility matcher.
|
|
13
|
+
* This is intentionally generic so the underlying engine can evolve
|
|
14
|
+
* without a breaking public API. Keys/values are implementation-defined.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* await expect(container).toBeAccessible({
|
|
18
|
+
* rules: { 'color-contrast': { enabled: false } },
|
|
19
|
+
* runOnly: ['wcag2a', 'wcag2aa'],
|
|
20
|
+
* });
|
|
21
|
+
*/
|
|
22
|
+
export interface HtmlA11yOptions {
|
|
23
|
+
/** Arbitrary engine-specific options (e.g., rules, tags, runOnly, selectors). */
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
declare module 'vitest' {
|
|
28
|
+
interface Assertion<T = any> {
|
|
29
|
+
/**
|
|
30
|
+
* Assert that a DOM container (Element/Document/DocumentFragment)
|
|
31
|
+
* has no accessibility violations, as determined by the toolkit’s
|
|
32
|
+
* internal engine.
|
|
33
|
+
*
|
|
34
|
+
* The matcher is registered by importing:
|
|
35
|
+
* `@ovh-ux/manager-static-analysis-kit/tests/html-a11y-tests-setup`
|
|
36
|
+
* in your test setup file.
|
|
37
|
+
*
|
|
38
|
+
* @param options Optional engine-specific configuration (see {@link HtmlA11yOptions}).
|
|
39
|
+
* @returns A promise that resolves when the assertion completes.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* const { container } = render(<Button />);
|
|
43
|
+
* await expect(container).toBeAccessible();
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* await expect(container).toBeAccessible({
|
|
47
|
+
* rules: { 'color-contrast': { enabled: false } },
|
|
48
|
+
* });
|
|
49
|
+
*/
|
|
50
|
+
toBeAccessible(options?: HtmlA11yOptions): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface AsymmetricMatchersContaining {
|
|
54
|
+
/**
|
|
55
|
+
* Asymmetric form of {@link Assertion.toBeAccessible}.
|
|
56
|
+
* Useful with `.not` and partial matching expressions.
|
|
57
|
+
*
|
|
58
|
+
* @param options Optional engine-specific configuration.
|
|
59
|
+
*/
|
|
60
|
+
toBeAccessible(options?: HtmlA11yOptions): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html-a11y-tests-setup.d.ts","sourceRoot":"","sources":["../../../../src/adapters/html-a11y-validation/helpers/html-a11y-tests-setup.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import axe from 'axe-core';
|
|
2
|
+
import { expect } from 'vitest';
|
|
3
|
+
const NODE = { ELEMENT: 1, DOCUMENT: 9, FRAGMENT: 11 };
|
|
4
|
+
function assertSupportedDomEnv() {
|
|
5
|
+
const ua = typeof navigator !== 'undefined' && navigator.userAgent
|
|
6
|
+
? navigator.userAgent.toLowerCase()
|
|
7
|
+
: '';
|
|
8
|
+
if (ua.includes('happy-dom')) {
|
|
9
|
+
throw new Error('[a11y] The test DOM environment is not supported. Please use the "jsdom" environment.');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function isDomNode(x) {
|
|
13
|
+
return typeof x === 'object' && x !== null && 'nodeType' in x;
|
|
14
|
+
}
|
|
15
|
+
function isElement(x) {
|
|
16
|
+
return isDomNode(x) && x.nodeType === NODE.ELEMENT;
|
|
17
|
+
}
|
|
18
|
+
function isDocument(x) {
|
|
19
|
+
return isDomNode(x) && x.nodeType === NODE.DOCUMENT;
|
|
20
|
+
}
|
|
21
|
+
function isFragment(x) {
|
|
22
|
+
return isDomNode(x) && x.nodeType === NODE.FRAGMENT;
|
|
23
|
+
}
|
|
24
|
+
function toAxeContext(target) {
|
|
25
|
+
if (isElement(target) || isDocument(target)) {
|
|
26
|
+
return target;
|
|
27
|
+
}
|
|
28
|
+
if (isFragment(target)) {
|
|
29
|
+
const elements = Array.from(target.childNodes).filter((n) => n.nodeType === NODE.ELEMENT);
|
|
30
|
+
if (elements.length > 0) {
|
|
31
|
+
return { include: elements };
|
|
32
|
+
}
|
|
33
|
+
return { include: [target] };
|
|
34
|
+
}
|
|
35
|
+
return target;
|
|
36
|
+
}
|
|
37
|
+
async function runA11y(target, options) {
|
|
38
|
+
const context = toAxeContext(target);
|
|
39
|
+
const opts = options ?? {};
|
|
40
|
+
return axe.run(context, opts);
|
|
41
|
+
}
|
|
42
|
+
function formatViolations(results) {
|
|
43
|
+
const { violations } = results;
|
|
44
|
+
if (!violations.length)
|
|
45
|
+
return '';
|
|
46
|
+
return violations
|
|
47
|
+
.map((v) => {
|
|
48
|
+
const where = v.nodes
|
|
49
|
+
.map((n) => ` - ${n.target.join(' ')}${n.failureSummary ? `\n ${n.failureSummary}` : ''}`)
|
|
50
|
+
.join('\n');
|
|
51
|
+
return `[${v.id}] ${v.help}\n${where}\nMore: ${v.helpUrl}`;
|
|
52
|
+
})
|
|
53
|
+
.join('\n\n');
|
|
54
|
+
}
|
|
55
|
+
expect.extend({
|
|
56
|
+
async toBeAccessible(received, options) {
|
|
57
|
+
assertSupportedDomEnv();
|
|
58
|
+
if (!isDomNode(received)) {
|
|
59
|
+
return {
|
|
60
|
+
pass: false,
|
|
61
|
+
message: () => 'toBeAccessible(): expected an Element/Document/DocumentFragment (e.g., RTL container).',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const results = await runA11y(received, options);
|
|
65
|
+
const pass = results.violations.length === 0;
|
|
66
|
+
return {
|
|
67
|
+
pass,
|
|
68
|
+
message: () => pass
|
|
69
|
+
? 'expected container NOT to be accessible, but no violations were found'
|
|
70
|
+
: `Accessibility violations found (${results.violations.length}):\n\n${formatViolations(results)}`,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
});
|