@ovh-ux/manager-static-analysis-kit 0.8.0 → 0.10.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +59 -69
  3. package/bin/cli-path-config.js +31 -0
  4. package/bin/code-duplication-cli.js +96 -0
  5. package/bin/code-duplication-tests-cli.js +68 -0
  6. package/bin/lint-cli.js +1 -1
  7. package/bin/perf-budgets-cli.js +58 -297
  8. package/bin/perf-budgets-tests-cli.js +1 -3
  9. package/bin/static-dynamic-quality-checks.js +138 -0
  10. package/bin/tests-coverage-cli-tests.js +67 -0
  11. package/bin/tests-coverage-cli.js +137 -0
  12. package/bin/types-coverage-cli.js +195 -0
  13. package/bin/types-coverage-tests-cli.js +68 -0
  14. package/bin/utils/apps-utils.js +64 -0
  15. package/bin/utils/args-parse-utils.js +229 -0
  16. package/bin/utils/file-utils.js +52 -0
  17. package/bin/utils/log-utils.js +29 -0
  18. package/bin/utils/runner-utils.js +85 -0
  19. package/bin/utils/turbo-utils.js +150 -0
  20. package/dist/adapters/code-duplication/helpers/code-duplication-analysis-helper.d.ts +9 -0
  21. package/dist/adapters/code-duplication/helpers/code-duplication-analysis-helper.d.ts.map +1 -0
  22. package/dist/adapters/code-duplication/helpers/code-duplication-analysis-helper.js +294 -0
  23. package/dist/adapters/code-duplication/types/CodeDuplication.d.ts +63 -0
  24. package/dist/adapters/code-duplication/types/CodeDuplication.d.ts.map +1 -0
  25. package/dist/adapters/code-duplication/types/CodeDuplication.js +1 -0
  26. package/dist/adapters/eslint/rules/eslint-prettier.js +1 -1
  27. package/dist/adapters/html-a11y-validation/helpers/html-a11y-tests-setup.d.ts +62 -0
  28. package/dist/adapters/html-a11y-validation/helpers/html-a11y-tests-setup.d.ts.map +1 -0
  29. package/dist/adapters/html-a11y-validation/helpers/html-a11y-tests-setup.js +73 -0
  30. package/dist/adapters/html-w3c-validation/helpers/html-w3c-tests-setup.d.ts +19 -0
  31. package/dist/adapters/html-w3c-validation/helpers/html-w3c-tests-setup.d.ts.map +1 -0
  32. package/dist/adapters/html-w3c-validation/helpers/html-w3c-tests-setup.js +32 -0
  33. package/dist/adapters/html-w3c-validation/helpers/html-w3c-validator.d.ts +4 -0
  34. package/dist/adapters/html-w3c-validation/helpers/html-w3c-validator.d.ts.map +1 -0
  35. package/dist/adapters/html-w3c-validation/helpers/html-w3c-validator.js +89 -0
  36. package/dist/adapters/html-w3c-validation/types/HtmlW3CValidator.d.ts +6 -0
  37. package/dist/adapters/html-w3c-validation/types/HtmlW3CValidator.d.ts.map +1 -0
  38. package/dist/adapters/html-w3c-validation/types/HtmlW3CValidator.js +1 -0
  39. package/dist/adapters/html-w3c-validation/types/globals/vnu-jar.d.ts +5 -0
  40. package/dist/adapters/perf-budgets/helpers/perfs-bundle-analysis-helper.d.ts +1 -1
  41. package/dist/adapters/perf-budgets/helpers/perfs-bundle-analysis-helper.d.ts.map +1 -1
  42. package/dist/adapters/perf-budgets/helpers/perfs-bundle-analysis-helper.js +2 -3
  43. package/dist/adapters/tests-coverage/helpers/tests-coverage-analysis-helper.d.ts +4 -0
  44. package/dist/adapters/tests-coverage/helpers/tests-coverage-analysis-helper.d.ts.map +1 -0
  45. package/dist/adapters/tests-coverage/helpers/tests-coverage-analysis-helper.js +215 -0
  46. package/dist/adapters/tests-coverage/types/TestsCoverage.d.ts +36 -0
  47. package/dist/adapters/tests-coverage/types/TestsCoverage.d.ts.map +1 -0
  48. package/dist/adapters/tests-coverage/types/TestsCoverage.js +6 -0
  49. package/dist/adapters/types-coverage/helpers/types-coverage-analysis-helper.d.ts +4 -0
  50. package/dist/adapters/types-coverage/helpers/types-coverage-analysis-helper.d.ts.map +1 -0
  51. package/dist/adapters/types-coverage/helpers/types-coverage-analysis-helper.js +231 -0
  52. package/dist/adapters/types-coverage/types/TypesCoverage.d.ts +38 -0
  53. package/dist/adapters/types-coverage/types/TypesCoverage.d.ts.map +1 -0
  54. package/dist/adapters/types-coverage/types/TypesCoverage.js +1 -0
  55. package/dist/configs/architecture-analysis-config.d.ts +5 -0
  56. package/dist/configs/architecture-analysis-config.d.ts.map +1 -0
  57. package/dist/configs/{madge-config.js → architecture-analysis-config.js} +1 -1
  58. package/dist/configs/code-duplication-config.d.ts +22 -0
  59. package/dist/configs/code-duplication-config.d.ts.map +1 -0
  60. package/dist/configs/code-duplication-config.js +22 -0
  61. package/dist/configs/{prettier-config.d.ts → code-formatter-config.d.ts} +1 -1
  62. package/dist/configs/code-formatter-config.d.ts.map +1 -0
  63. package/dist/configs/file-globs-config.d.ts +0 -3
  64. package/dist/configs/file-globs-config.d.ts.map +1 -1
  65. package/dist/configs/file-globs-config.js +0 -3
  66. package/dist/configs/naming-conventions-config.d.ts.map +1 -1
  67. package/dist/configs/naming-conventions-config.js +2 -0
  68. package/dist/configs/shared-exclusion-patterns.d.ts +1 -0
  69. package/dist/configs/shared-exclusion-patterns.d.ts.map +1 -1
  70. package/dist/configs/shared-exclusion-patterns.js +23 -1
  71. package/dist/configs/tests-coverage-config.d.ts +12 -0
  72. package/dist/configs/tests-coverage-config.d.ts.map +1 -0
  73. package/dist/configs/tests-coverage-config.js +11 -0
  74. package/dist/configs/types-coverage-config.d.ts +33 -0
  75. package/dist/configs/types-coverage-config.d.ts.map +1 -0
  76. package/dist/configs/types-coverage-config.js +24 -0
  77. package/dist/tsconfig.tsbuildinfo +1 -1
  78. package/package.json +32 -7
  79. package/bin/cli-utils.js +0 -56
  80. package/dist/configs/jscpd-config.d.ts +0 -7
  81. package/dist/configs/jscpd-config.d.ts.map +0 -1
  82. package/dist/configs/jscpd-config.js +0 -7
  83. package/dist/configs/madge-config.d.ts +0 -5
  84. package/dist/configs/madge-config.d.ts.map +0 -1
  85. package/dist/configs/prettier-config.d.ts.map +0 -1
  86. /package/dist/configs/{prettier-config.js → code-formatter-config.js} +0 -0
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+
6
+ import {
7
+ collectTestsCoverage,
8
+ generateTestsCoverageHtml,
9
+ } from '../dist/adapters/tests-coverage/helpers/tests-coverage-analysis-helper.js';
10
+ import {
11
+ appsDir,
12
+ outputRootDir,
13
+ rootDir,
14
+ testsCoverageCombinedHtmlReportName,
15
+ testsCoverageCombinedJsonReportName,
16
+ testsCoverageReportsRootDirName,
17
+ } from './cli-path-config.js';
18
+ import { parseCliTargets } from './utils/args-parse-utils.js';
19
+ import { logError, logInfo, logWarn } from './utils/log-utils.js';
20
+ import { runAppsAnalysis } from './utils/runner-utils.js';
21
+ import { resolveTurboFilters, runTurboBuild, runTurboTests } from './utils/turbo-utils.js';
22
+
23
+ /**
24
+ * Locate common coverage artifacts inside an app directory.
25
+ * @param {string} appDir
26
+ * @returns {{ summary: string | null, lcov: string | null }}
27
+ */
28
+ function findCoverageFiles(appDir) {
29
+ const candidates = [
30
+ // Vitest & Jest default
31
+ path.join(appDir, 'coverage', 'coverage-summary.json'),
32
+ // Sometimes coverage is nested (e.g., project subdir)
33
+ // We’ll scan one level deep for coverage-summary.json
34
+ ];
35
+
36
+ let summary = candidates.find((p) => fs.existsSync(p));
37
+ if (!summary) {
38
+ // one-level deep scan
39
+ for (const entry of fs.readdirSync(appDir, { withFileTypes: true })) {
40
+ if (!entry.isDirectory()) continue;
41
+ if (['node_modules', 'dist', 'build', '.next', 'coverage'].includes(entry.name)) continue;
42
+ const p = path.join(appDir, entry.name, 'coverage', 'coverage-summary.json');
43
+ if (fs.existsSync(p)) {
44
+ summary = p;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+
50
+ // Best-effort extras (not mandatory)
51
+ const lcov =
52
+ (summary && path.join(path.dirname(summary), 'lcov.info')) ||
53
+ path.join(appDir, 'coverage', 'lcov.info');
54
+
55
+ return { summary, lcov: fs.existsSync(lcov) ? lcov : null };
56
+ }
57
+
58
+ /**
59
+ * Copy an app’s coverage files into tests-coverage-reports/<app>.
60
+ * @param {string} appDir
61
+ * @param {string} appShortName
62
+ * @returns {boolean} True if a summary was found and copied
63
+ */
64
+ function harvestAppCoverage(appDir, appShortName) {
65
+ const { summary, lcov } = findCoverageFiles(appDir);
66
+ const outDir = path.join(outputRootDir, testsCoverageReportsRootDirName, appShortName);
67
+ fs.mkdirSync(outDir, { recursive: true });
68
+
69
+ if (!summary) {
70
+ logWarn(`⚠️ No coverage-summary.json found for ${appShortName}`);
71
+ return false;
72
+ }
73
+
74
+ const targetSummary = path.join(outDir, 'coverage-summary.json');
75
+ fs.copyFileSync(summary, targetSummary);
76
+ logInfo(`✅ Copied summary → ${targetSummary}`);
77
+
78
+ if (lcov) {
79
+ const targetLcov = path.join(outDir, 'lcov.info');
80
+ fs.copyFileSync(lcov, targetLcov);
81
+ logInfo(`✅ Copied lcov → ${targetLcov}`);
82
+ }
83
+
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * Main CLI entrypoint: build → test (with coverage) → harvest → combine.
89
+ */
90
+ function main() {
91
+ try {
92
+ const { appFolders, packageNames } = parseCliTargets(appsDir);
93
+ const turboFilters = resolveTurboFilters({ appsDir, appFolders, packageNames });
94
+
95
+ // 1) Build all selected targets (best-effort)
96
+ runTurboBuild(rootDir, turboFilters, ['--continue']);
97
+
98
+ // 2) Run tests with coverage; keep args minimal for Vitest/Jest compatibility
99
+ // (Projects should already be configured to emit json-summary/lcov.)
100
+ runTurboTests(rootDir, turboFilters, [
101
+ '--continue', // turbo: keep going even if some tests fail
102
+ '--', // pass the rest to the test runner
103
+ '--coverage',
104
+ '--coverage.reportOnFailure',
105
+ '--coverage.reporter',
106
+ 'json-summary',
107
+ '--coverage.reporter',
108
+ 'lcov',
109
+ '--coverage.reporter',
110
+ 'html',
111
+ ]);
112
+
113
+ // 3) Per-app harvest + combined reports
114
+ runAppsAnalysis({
115
+ appsDir,
116
+ appFolders,
117
+ requireReact: false,
118
+ binaryLabel: 'Tests coverage',
119
+ appRunner: harvestAppCoverage,
120
+ reportsRootDirName: testsCoverageReportsRootDirName,
121
+ combinedJson: testsCoverageCombinedJsonReportName,
122
+ combinedHtml: testsCoverageCombinedHtmlReportName,
123
+ collectFn: collectTestsCoverage,
124
+ generateHtmlFn: (combined) =>
125
+ generateTestsCoverageHtml(
126
+ combined,
127
+ path.join(outputRootDir, testsCoverageReportsRootDirName),
128
+ ),
129
+ outputRootDir,
130
+ });
131
+ } catch (err) {
132
+ logError(`Fatal error: ${err.stack || err.message}`);
133
+ process.exit(1);
134
+ }
135
+ }
136
+
137
+ main();
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import ts from 'typescript';
6
+
7
+ import {
8
+ collectTypesCoverage,
9
+ generateTypesCoverageHtml,
10
+ } from '../dist/adapters/types-coverage/helpers/types-coverage-analysis-helper.js';
11
+ import {
12
+ appsDir,
13
+ outputRootDir,
14
+ tsTypesCoverageBin,
15
+ typesCoverageCombinedHtmlReportName,
16
+ typesCoverageCombinedJsonReportName,
17
+ typesCoverageReportsRootDirName,
18
+ } from './cli-path-config.js';
19
+ import { buildTypesCoverageArgs, parseCliTargets } from './utils/args-parse-utils.js';
20
+ import { logError, logInfo, logWarn } from './utils/log-utils.js';
21
+ import { ensureBinExists, runAppsAnalysis, runCommand } from './utils/runner-utils.js';
22
+
23
+ /**
24
+ * Temporarily strip "extends" from tsconfig.json before analysis,
25
+ * then restore it after.
26
+ *
27
+ * @param {string} appDir - Absolute app directory
28
+ * @param {() => boolean} fn - Callback running the analysis
29
+ * @returns {boolean} Result of the callback
30
+ */
31
+ function patchTsConfig(appDir, fn) {
32
+ const tsconfigPath = path.join(appDir, 'tsconfig.json');
33
+ if (!fs.existsSync(tsconfigPath)) {
34
+ return fn();
35
+ }
36
+
37
+ const original = fs.readFileSync(tsconfigPath, 'utf-8');
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(original);
41
+ } catch {
42
+ logWarn(`⚠️ Invalid tsconfig.json in ${appDir}, skipping patch`);
43
+ return fn();
44
+ }
45
+
46
+ let modified = null;
47
+ if (parsed.extends) {
48
+ modified = { ...parsed };
49
+ delete modified.extends;
50
+ fs.writeFileSync(tsconfigPath, JSON.stringify(modified, null, 2));
51
+ logInfo(`⏳ Patched tsconfig.json in ${appDir} (removed extends)`);
52
+ }
53
+
54
+ try {
55
+ return fn();
56
+ } finally {
57
+ if (modified) {
58
+ fs.writeFileSync(tsconfigPath, original);
59
+ logInfo(`🔄 Restored tsconfig.json in ${appDir}`);
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Count loose TypeScript constructs (`as`, `any`, `unknown`) in src/.
66
+ *
67
+ * Uses the TypeScript compiler API to avoid false positives from strings, comments, etc.
68
+ *
69
+ * @param {string} appDir Absolute path to app root
70
+ * @returns {{ as: number; any: number; unknown: number }}
71
+ */
72
+ export function countLooseTypes(appDir) {
73
+ const srcDir = path.join(appDir, 'src');
74
+ let asCount = 0;
75
+ let anyCount = 0;
76
+ let unknownCount = 0;
77
+
78
+ function analyzeFile(filePath) {
79
+ const content = fs.readFileSync(filePath, 'utf8');
80
+ const source = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
81
+
82
+ function visit(node) {
83
+ // "as" expressions: foo as string
84
+ if (ts.isAsExpression(node)) {
85
+ asCount++;
86
+ if (node.type.kind === ts.SyntaxKind.AnyKeyword) {
87
+ anyCount++;
88
+ } else if (node.type.kind === ts.SyntaxKind.UnknownKeyword) {
89
+ unknownCount++;
90
+ }
91
+ }
92
+
93
+ // Type annotations like let x: any or function foo(x: unknown)
94
+ if (node.kind === ts.SyntaxKind.AnyKeyword) {
95
+ anyCount++;
96
+ } else if (node.kind === ts.SyntaxKind.UnknownKeyword) {
97
+ unknownCount++;
98
+ }
99
+
100
+ ts.forEachChild(node, visit);
101
+ }
102
+
103
+ visit(source);
104
+ }
105
+
106
+ function walk(dir) {
107
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
108
+ const fullPath = path.join(dir, entry.name);
109
+ if (entry.isDirectory()) {
110
+ walk(fullPath);
111
+ } else if (/\.(ts|tsx)$/.test(entry.name)) {
112
+ analyzeFile(fullPath);
113
+ }
114
+ }
115
+ }
116
+
117
+ if (fs.existsSync(srcDir)) {
118
+ walk(srcDir);
119
+ }
120
+
121
+ return { as: asCount, any: anyCount, unknown: unknownCount };
122
+ }
123
+
124
+ /**
125
+ * Run TypeScript coverage for one app.
126
+ *
127
+ * @param {string} appDir - Absolute app directory
128
+ * @param {string} appShortName - Shortened app name
129
+ * @returns {boolean} True if analysis completed successfully
130
+ */
131
+ function runAppTypesCoverage(appDir, appShortName) {
132
+ return patchTsConfig(appDir, () => {
133
+ const absoluteOutputDir = path.join(
134
+ outputRootDir,
135
+ typesCoverageReportsRootDirName,
136
+ appShortName,
137
+ );
138
+ fs.mkdirSync(absoluteOutputDir, { recursive: true });
139
+
140
+ const relativeOutputDir = path.relative(appDir, absoluteOutputDir);
141
+ logInfo(`Running TypeScript coverage for ${appShortName} → ${absoluteOutputDir}`);
142
+
143
+ const args = buildTypesCoverageArgs(relativeOutputDir);
144
+ const ok = runCommand(tsTypesCoverageBin, args, appDir);
145
+
146
+ const looseTypes = countLooseTypes(appDir);
147
+ const looseTypeReport = path.join(absoluteOutputDir, 'loose-types.json');
148
+ fs.writeFileSync(looseTypeReport, JSON.stringify(looseTypes, null, 2));
149
+
150
+ const jsonReport = path.join(absoluteOutputDir, 'typescript-coverage.json');
151
+ const htmlReport = path.join(absoluteOutputDir, 'index.html');
152
+
153
+ if (!fs.existsSync(jsonReport)) {
154
+ logWarn(`⚠️ Missing typescript-coverage.json at ${jsonReport}`);
155
+ }
156
+ if (!fs.existsSync(htmlReport)) {
157
+ logWarn(`⚠️ Missing index.html at ${htmlReport}`);
158
+ }
159
+ if (!fs.existsSync(looseTypeReport)) {
160
+ logWarn(`⚠️ Missing loose-types.json at ${looseTypeReport}`);
161
+ }
162
+
163
+ return ok;
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Main CLI entrypoint for TypeScript coverage analysis.
169
+ */
170
+ function main() {
171
+ try {
172
+ ensureBinExists(tsTypesCoverageBin, 'typescript-coverage-report');
173
+
174
+ const { appFolders } = parseCliTargets(appsDir);
175
+
176
+ runAppsAnalysis({
177
+ appsDir,
178
+ appFolders,
179
+ requireReact: true,
180
+ binaryLabel: 'Type coverage',
181
+ appRunner: runAppTypesCoverage,
182
+ reportsRootDirName: typesCoverageReportsRootDirName,
183
+ combinedJson: typesCoverageCombinedJsonReportName,
184
+ combinedHtml: typesCoverageCombinedHtmlReportName,
185
+ collectFn: collectTypesCoverage,
186
+ generateHtmlFn: generateTypesCoverageHtml,
187
+ outputRootDir,
188
+ });
189
+ } catch (err) {
190
+ logError(`Fatal error: ${err.stack || err.message}`);
191
+ process.exit(1);
192
+ }
193
+ }
194
+
195
+ main();
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ import { logError, logInfo } from './utils/log-utils.js';
5
+
6
+ // Define the set of validation test cases for types-coverage-tests CLI
7
+ const cases = [
8
+ // Debug only — very slow, runs across all apps
9
+ // { name: 'Auto-discovery (all apps)', cmd: 'yarn manager-types-coverage', expect: 0 },
10
+
11
+ { name: 'Single app (zimbra)', cmd: 'yarn manager-types-coverage --app zimbra', expect: 0 },
12
+ {
13
+ name: 'Multiple apps (container,zimbra,web-ongoing-operations,key-management-service)',
14
+ cmd: 'yarn manager-types-coverage --apps container,zimbra,web-ongoing-operations,key-management-service',
15
+ expect: 0,
16
+ },
17
+ {
18
+ name: 'Single package (container)',
19
+ cmd: 'yarn manager-types-coverage --package @ovh-ux/manager-container-app',
20
+ expect: 0,
21
+ },
22
+ {
23
+ name: 'Multiple packages (zimbra,pci-workflow)',
24
+ cmd: 'yarn manager-types-coverage --packages @ovh-ux/manager-zimbra-app,@ovh-ux/manager-pci-workflow-app',
25
+ expect: 0,
26
+ },
27
+ {
28
+ name: 'Mixed valid+invalid apps',
29
+ cmd: 'yarn manager-types-coverage --apps zimbra,unknown-app,container',
30
+ expect: 0,
31
+ },
32
+ {
33
+ name: 'Mixed valid+invalid packages',
34
+ cmd: 'yarn manager-types-coverage --packages @ovh-ux/manager-zimbra-app,@ovh-ux/manager-ghost-app',
35
+ expect: 0,
36
+ },
37
+ {
38
+ name: 'All invalid apps',
39
+ cmd: 'yarn manager-types-coverage --apps doesnotexist1,doesnotexist2',
40
+ expect: 1,
41
+ },
42
+ {
43
+ name: 'All invalid packages',
44
+ cmd: 'yarn manager-types-coverage --packages @ovh-ux/manager-ghost-app,@ovh-ux/manager-missing-app',
45
+ expect: 1,
46
+ },
47
+ ];
48
+
49
+ let failed = 0;
50
+
51
+ for (const test of cases) {
52
+ logInfo(`\n🔎 Running: ${test.name}`);
53
+ const [bin, ...args] = test.cmd.split(' ');
54
+ const result = spawnSync(bin, args, { stdio: 'inherit', shell: true });
55
+
56
+ if (result.status !== test.expect) {
57
+ logError(`❌ ${test.name} failed (expected exit ${test.expect}, got ${result.status})`);
58
+ failed++;
59
+ } else {
60
+ logInfo(`✅ ${test.name} passed`);
61
+ }
62
+ }
63
+
64
+ if (failed > 0) {
65
+ logError(`\n❌ ${failed} test(s) failed.`);
66
+ } else {
67
+ logInfo('\n🎉 All types-coverage-tests CLI validation tests passed!');
68
+ }
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { readJsonSafe } from './file-utils.js';
5
+ import { logError, logWarn } from './log-utils.js';
6
+
7
+ /**
8
+ * Check if a given package.json belongs to a React app.
9
+ */
10
+ export function isReactApp(pkgPath) {
11
+ try {
12
+ if (!fs.existsSync(pkgPath)) return false;
13
+
14
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
15
+ if (!pkg || typeof pkg !== 'object') return false;
16
+
17
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies };
18
+ return Boolean(deps.react);
19
+ } catch (err) {
20
+ logWarn(`Failed to read or parse ${pkgPath}: ${err.message}`);
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Resolve app short name from package.json (fallback to folder).
27
+ */
28
+ export function getAppShortName(pkgPath, folder) {
29
+ const pkg = readJsonSafe(pkgPath) || {};
30
+ return (pkg.name && String(pkg.name).replace(/^@ovh-ux\//, '')) || folder;
31
+ }
32
+
33
+ /**
34
+ * Unified app info resolver.
35
+ * - Ensures folder exists
36
+ * - Ensures package.json exists
37
+ * - Optionally enforces React app
38
+ */
39
+ export function resolveAppInfo(appsDir, appFolder, { requireReact = false } = {}) {
40
+ const appDir = path.join(appsDir, appFolder);
41
+ if (!fs.existsSync(appDir)) {
42
+ logError(`❌ App folder not found: ${appFolder} → skipping`);
43
+ return null;
44
+ }
45
+
46
+ const pkgPath = path.join(appDir, 'package.json');
47
+ if (!fs.existsSync(pkgPath)) {
48
+ logWarn(`⚠️ No package.json for ${appFolder}, skipping`);
49
+ return null;
50
+ }
51
+
52
+ if (requireReact && !isReactApp(pkgPath)) {
53
+ logWarn(`⚠️ ${appFolder} is not a React app, skipping`);
54
+ return null;
55
+ }
56
+
57
+ return {
58
+ appDir,
59
+ pkgPath,
60
+ appFolder,
61
+ appShortName: getAppShortName(pkgPath, appFolder),
62
+ pkg: readJsonSafe(pkgPath),
63
+ };
64
+ }
@@ -0,0 +1,229 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+
5
+ import { codeDuplicationConfig } from '../../dist/configs/code-duplication-config.js';
6
+ import { typesCoverageConfig } from '../../dist/configs/types-coverage-config.js';
7
+ import { isReactApp } from './apps-utils.js';
8
+ import { logError, logInfo, logWarn } from './log-utils.js';
9
+
10
+ /**
11
+ * Extract an app folder name from an OVH Manager package name.
12
+ *
13
+ * Example:
14
+ * "@ovh-ux/manager-zimbra-app" → "zimbra"
15
+ *
16
+ * @param {string} packageName - Full npm package name.
17
+ * @returns {string|null} The extracted app folder name, or null if the format is invalid.
18
+ */
19
+ function extractAppFolderFromPackageName(packageName) {
20
+ const match = packageName.match(/^@ovh-ux\/manager-(.+)-app$/);
21
+ if (!match) {
22
+ logError(`❌ Invalid package name format: ${packageName}`);
23
+ return null;
24
+ }
25
+ return match[1];
26
+ }
27
+
28
+ /**
29
+ * Parse CLI arguments for `--apps` or `--app` flags.
30
+ *
31
+ * Accepts a comma-separated list of app folder names. Example:
32
+ * `--apps zimbra,web`
33
+ *
34
+ * @param {string} appsDir - Absolute path to the apps root directory.
35
+ * @returns {{appFolders: string[], packageNames: string[]} | null}
36
+ * Object containing valid app folders and an empty `packageNames` array,
37
+ * or null if no `--apps`/`--app` flag is provided.
38
+ */
39
+ function parseAppsArg(appsDir) {
40
+ const appsArg = process.argv.find((arg) => arg === '--apps' || arg === '--app');
41
+ if (!appsArg) return null;
42
+
43
+ const index = process.argv.indexOf(appsArg);
44
+ const rawValue = process.argv[index + 1] || '';
45
+ const appFolders = rawValue
46
+ .split(',')
47
+ .map((n) => n.trim())
48
+ .filter(Boolean);
49
+
50
+ const validFolders = appFolders.filter((folder) => {
51
+ if (!fs.existsSync(path.join(appsDir, folder))) {
52
+ logError(`❌ App not found: ${folder} → skipping`);
53
+ return false;
54
+ }
55
+ return true;
56
+ });
57
+
58
+ logInfo(`Running in apps mode for: ${validFolders.join(', ')}`);
59
+ return { appFolders: validFolders, packageNames: [] };
60
+ }
61
+
62
+ /**
63
+ * Parse CLI arguments for `--packages` or `--package` flags.
64
+ *
65
+ * Accepts a comma-separated list of package names. Example:
66
+ * `--packages @ovh-ux/manager-zimbra-app,@ovh-ux/manager-web-app`
67
+ *
68
+ * Each package name is mapped to its corresponding app folder.
69
+ *
70
+ * @param {string} appsDir - Absolute path to the apps root directory.
71
+ * @returns {{appFolders: string[], packageNames: string[]} | null}
72
+ * Object mapping package names to discovered app folders,
73
+ * or null if no `--packages`/`--package` flag is provided.
74
+ */
75
+ function parsePackagesArg(appsDir) {
76
+ const packagesArg = process.argv.find((arg) => arg === '--packages' || arg === '--package');
77
+ if (!packagesArg) return null;
78
+
79
+ const index = process.argv.indexOf(packagesArg);
80
+ const rawValue = process.argv[index + 1] || '';
81
+ const packageNames = rawValue
82
+ .split(',')
83
+ .map((n) => n.trim())
84
+ .filter(Boolean);
85
+
86
+ const mappedFolders = [];
87
+ for (const pkgName of packageNames) {
88
+ const folder = extractAppFolderFromPackageName(pkgName);
89
+ if (folder && fs.existsSync(path.join(appsDir, folder))) {
90
+ mappedFolders.push(folder);
91
+ } else {
92
+ logError(`❌ Package ${pkgName} → app folder ${folder} not found, skipping`);
93
+ }
94
+ }
95
+
96
+ logInfo(
97
+ `Running in packages mode for: ${packageNames.join(', ')} → apps: ${mappedFolders.join(', ')}`,
98
+ );
99
+ return { appFolders: mappedFolders, packageNames };
100
+ }
101
+
102
+ /**
103
+ * Discover all React apps in the apps directory when no CLI flags are provided.
104
+ *
105
+ * Only apps that contain a `package.json` file and are identified as React apps
106
+ * (via `isReactApp`) will be included.
107
+ *
108
+ * @param {string} appsDir - Absolute path to the apps root directory.
109
+ * @returns {{appFolders: string[], packageNames: string[]}}
110
+ * Object containing discovered app folders and an empty `packageNames` array.
111
+ */
112
+ function discoverApps(appsDir) {
113
+ const discoveredAppFolders = fs.readdirSync(appsDir).filter((dir) => {
114
+ const pkgPath = path.join(appsDir, dir, 'package.json');
115
+ return fs.existsSync(pkgPath) && isReactApp(pkgPath);
116
+ });
117
+
118
+ if (discoveredAppFolders.length === 0) {
119
+ logWarn('⚠️ No React apps found to analyze.');
120
+ return { appFolders: [], packageNames: [] };
121
+ }
122
+
123
+ logInfo(`Running in auto-discovery mode (${discoveredAppFolders.length} apps).`);
124
+ return { appFolders: discoveredAppFolders, packageNames: [] };
125
+ }
126
+
127
+ /**
128
+ * Parse CLI arguments to resolve which apps or packages should be analyzed.
129
+ *
130
+ * Priority order:
131
+ * 1. Explicit `--apps` flag
132
+ * 2. Explicit `--packages` flag
133
+ * 3. Auto-discovery fallback
134
+ *
135
+ * @param {string} appsDir - Absolute path to the apps root directory.
136
+ * @returns {{appFolders: string[], packageNames: string[]}}
137
+ * Object containing app folder names and package names for analysis.
138
+ */
139
+ export function parseCliTargets(appsDir) {
140
+ return parseAppsArg(appsDir) || parsePackagesArg(appsDir) || discoverApps(appsDir);
141
+ }
142
+
143
+ /**
144
+ * Build CLI arguments for `typescript-coverage-report` from config.
145
+ *
146
+ * @param {string} outputDir - Output directory for the report.
147
+ * @returns {string[]} Array of CLI arguments.
148
+ */
149
+ export function buildTypesCoverageArgs(outputDir) {
150
+ const args = ['--outputDir', outputDir];
151
+
152
+ const mapping = {
153
+ threshold: '--threshold',
154
+ strict: '--strict',
155
+ debug: '--debug',
156
+ cache: '--cache',
157
+ project: '--project',
158
+ ignoreUnread: '--ignore-unread',
159
+ };
160
+
161
+ for (const [key, flag] of Object.entries(mapping)) {
162
+ const value = typesCoverageConfig[key];
163
+ if (value === undefined || value === false) continue;
164
+
165
+ if (typeof value === 'boolean') {
166
+ if (value) args.push(flag);
167
+ } else {
168
+ args.push(flag, String(value));
169
+ }
170
+ }
171
+
172
+ // Handle array options separately
173
+ if (Array.isArray(typesCoverageConfig.ignoreFiles)) {
174
+ for (const pattern of typesCoverageConfig.ignoreFiles) {
175
+ args.push('--ignore-files', pattern);
176
+ }
177
+ }
178
+
179
+ return args;
180
+ }
181
+
182
+ /**
183
+ * Translate our config → jscpd CLI flags.
184
+ * @param {string} outputDir - output directory for jscpd reports
185
+ * @returns {string[]}
186
+ */
187
+ export function buildCodeDuplicationArgs(outputDir) {
188
+ const cfg = codeDuplicationConfig;
189
+ const args = [
190
+ '--output',
191
+ outputDir,
192
+ '--reporters',
193
+ Array.isArray(cfg.reporters) ? cfg.reporters.join(',') : String(cfg.reporters || 'console'),
194
+ '--mode',
195
+ cfg.mode,
196
+ '--min-lines',
197
+ String(cfg.minLines),
198
+ '--min-tokens',
199
+ String(cfg.minTokens),
200
+ '--max-lines',
201
+ String(cfg.maxLines),
202
+ '--max-size',
203
+ String(cfg.maxSize),
204
+ ];
205
+
206
+ if (cfg.threshold !== null && cfg.threshold !== undefined) {
207
+ args.push('--threshold', String(cfg.threshold));
208
+ }
209
+ if (cfg.absolute) {
210
+ args.push('--absolute');
211
+ }
212
+ if (Array.isArray(cfg.formats) && cfg.formats.length > 0) {
213
+ args.push('--format', cfg.formats.join(','));
214
+ }
215
+ if (Array.isArray(cfg.ignore) && cfg.ignore.length > 0) {
216
+ // jscpd accepts comma-separated ignore globs
217
+ // https://github.com/kucherenko/jscpd/blob/master/apps/jscpd/README.md#ignore
218
+ args.push('--ignore', cfg.ignore.join(','));
219
+ }
220
+ // jscpd accepts regex patterns via `--ignore-pattern` (singular).
221
+ // Multiple regexes should be joined by comma.
222
+ // https://github.com/kucherenko/jscpd/blob/master/apps/jscpd/README.md#ignore-pattern
223
+ if (Array.isArray(cfg.ignorePatterns) && cfg.ignorePatterns.length > 0) {
224
+ for (const pat of cfg.ignorePatterns) {
225
+ args.push('--ignore-pattern', `'${pat}'`);
226
+ }
227
+ }
228
+ return args;
229
+ }