@terra-symposium/standards-quality 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/README.md +8 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/node-reporter.d.ts +79 -0
- package/dist/node-reporter.d.ts.map +1 -0
- package/dist/node-reporter.js +217 -0
- package/dist/node-reporter.js.map +1 -0
- package/dist/playwright-reporter.d.ts +107 -0
- package/dist/playwright-reporter.d.ts.map +1 -0
- package/dist/playwright-reporter.js +138 -0
- package/dist/playwright-reporter.js.map +1 -0
- package/dist/progress.d.ts +5 -2
- package/dist/progress.d.ts.map +1 -1
- package/dist/progress.js +6 -4
- package/dist/progress.js.map +1 -1
- package/dist/runner.d.ts +3 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +580 -86
- package/dist/runner.js.map +1 -1
- package/dist/single-use.d.ts +2 -1
- package/dist/single-use.d.ts.map +1 -1
- package/dist/single-use.js +42 -15
- package/dist/single-use.js.map +1 -1
- package/dist/test-workspace.d.ts +6 -0
- package/dist/test-workspace.d.ts.map +1 -0
- package/dist/test-workspace.js +48 -0
- package/dist/test-workspace.js.map +1 -0
- package/dist/ui-recommendations.d.ts +43 -0
- package/dist/ui-recommendations.d.ts.map +1 -0
- package/dist/ui-recommendations.js +182 -0
- package/dist/ui-recommendations.js.map +1 -0
- package/dist/workspace-test-runner.d.ts +6 -0
- package/dist/workspace-test-runner.d.ts.map +1 -0
- package/dist/workspace-test-runner.js +48 -0
- package/dist/workspace-test-runner.js.map +1 -0
- package/package.json +2 -2
- package/quality.schema.json +195 -130
package/dist/runner.js
CHANGED
|
@@ -16,12 +16,14 @@ import { collapseInlineIfs } from './inline-if.js';
|
|
|
16
16
|
import { collapseTypesAndObjects } from './collapse.js';
|
|
17
17
|
import { deadCodeEditsIntroduceTypeErrors, deadCodeTypeProgram, fixSingleUseDeclarations, singleUseFindings } from './single-use.js';
|
|
18
18
|
import { documentSrcFile, documentationFindings, duplicateImportFindings, maintainSrcFile, protectLongImports } from './documentation.js';
|
|
19
|
-
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { fixObjectForwarding, objectForwardingFindings } from './object-forwarding.js';
|
|
22
22
|
import { fixRegexDedup, fixRepeatedClasses, regexFindings, repeatedClassFindings, runTailwindCanonical, uiPatternFindings } from './patterns.js';
|
|
23
23
|
import { qualityProgress } from './progress.js';
|
|
24
|
+
import { qualityNodeReporterOptions } from './node-reporter.js';
|
|
24
25
|
import { securityFindings } from './security.js';
|
|
26
|
+
import { uiComponentRecommendations } from './ui-recommendations.js';
|
|
25
27
|
import { spawnSync } from 'node:child_process';
|
|
26
28
|
import { srcFiles, workingSrcFiles } from './files.js';
|
|
27
29
|
import { writeHistoryReport } from './history-report.js';
|
|
@@ -31,6 +33,12 @@ import { PROTECTED_IDENTIFIERS } from './naming.js';
|
|
|
31
33
|
/*//////////////////////////////////////////////////
|
|
32
34
|
REGEX
|
|
33
35
|
//////////////////////////////////////////////////*/
|
|
36
|
+
const BPLAYWRIGHT_B_U_REGEX = /\bplaywright\b/u;
|
|
37
|
+
const BWORKSPACE_TEST_RUNNER_JS_B_U_REGEX = /\bworkspace-test-runner\.js\b/u;
|
|
38
|
+
const NON_WORKSPACE_DIRS = new Set(['.git', '.next', '.nuxt', '.turbo', '.vercel', '.vite', '.astro', 'build', 'coverage', 'dist', 'node_modules', 'out', 'vendor']);
|
|
39
|
+
/*//////////////////////////////////////////////////
|
|
40
|
+
REGEX
|
|
41
|
+
//////////////////////////////////////////////////*/
|
|
34
42
|
const R_N_REGEX = /\r?\n/;
|
|
35
43
|
const SHARED_REGEX = /\/\*|\/\//;
|
|
36
44
|
/*//////////////////////////////////////////////////
|
|
@@ -56,6 +64,7 @@ const CLI_CMDS = new Set([
|
|
|
56
64
|
'tailwind-canonical',
|
|
57
65
|
'regex-dedup',
|
|
58
66
|
'ui-patterns',
|
|
67
|
+
'ui-recommendations',
|
|
59
68
|
'imports',
|
|
60
69
|
'aliases',
|
|
61
70
|
'forwarding',
|
|
@@ -74,11 +83,13 @@ const ESLINT_GLOB_SPECIALS = new Set(['*', '?', '[', ']', '{', '}', '(', ')', '!
|
|
|
74
83
|
let reportStages;
|
|
75
84
|
let reportStageName;
|
|
76
85
|
let reportGitHubData;
|
|
86
|
+
let outputPhase;
|
|
77
87
|
/*//////////////////////////////////////////////////
|
|
78
88
|
REGEX
|
|
79
89
|
//////////////////////////////////////////////////*/
|
|
80
90
|
const C_M_JS_TS_X_REGEX = /\.(?:c|m)?(?:js|ts)x?$/;
|
|
81
91
|
const C_M_JS_TS_X_CLASS_2 = /\.(?:(?:c|m)?(?:js|ts)x?)$/;
|
|
92
|
+
const WORKSPACE_MANIFESTS = new Map();
|
|
82
93
|
const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g');
|
|
83
94
|
/*//////////////////////////////////////////////////
|
|
84
95
|
HELPERS
|
|
@@ -235,9 +246,294 @@ function qualityHeader(name, detail) {
|
|
|
235
246
|
reportStageName = name;
|
|
236
247
|
reportStages.set(name, { name, status: 'passed', findings: 0 });
|
|
237
248
|
}
|
|
249
|
+
if (outputPhase) {
|
|
250
|
+
const indent = name.startsWith('dead-code/') || (outputPhase === 'Tests' && name.startsWith('command:')) ? ' ' : ' ';
|
|
251
|
+
const labels = {
|
|
252
|
+
'dead-code/safe-fixes': 'Safe fixes',
|
|
253
|
+
'dead-code/verification': 'Verification',
|
|
254
|
+
'dead-code/knip-fix': 'Knip import-consumer scan',
|
|
255
|
+
'dead-code/eslint-fix': 'ESLint unused-declaration scan',
|
|
256
|
+
'dead-code/single-use': 'Single-use declaration/reference scan and classification',
|
|
257
|
+
'dead-code/knip': 'Knip reachability verification',
|
|
258
|
+
'dead-code': 'Dead code',
|
|
259
|
+
};
|
|
260
|
+
const label = labels[name] ?? name.replace(/^command:/u, '').replace(/(^|[-/])([a-z])/gu, (_match, separator, letter) => `${separator === '-' ? ' ' : separator}${letter.toUpperCase()}`);
|
|
261
|
+
console.error(`${indent}${label}`);
|
|
262
|
+
if (detail)
|
|
263
|
+
console.error(`${indent} ${detail}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
238
266
|
console.error(`\n=== ${name} ===`);
|
|
239
267
|
console.error(detail);
|
|
240
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Starts one fixed output phase for the full quality command.
|
|
271
|
+
*
|
|
272
|
+
* @param phase Phase heading owned by the Quality runner.
|
|
273
|
+
*
|
|
274
|
+
* @return Nothing; the heading is written once before the phase checks.
|
|
275
|
+
*/
|
|
276
|
+
function beginQualityPhase(phase) {
|
|
277
|
+
outputPhase = phase;
|
|
278
|
+
console.error(`\n${phase.toUpperCase()}`);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Finds package manifests once so filtered workspace scripts can be classified.
|
|
282
|
+
*
|
|
283
|
+
* @param root Repository root whose package scripts are selected.
|
|
284
|
+
*
|
|
285
|
+
* @return Workspace package manifests outside generated and dependency directories.
|
|
286
|
+
*/
|
|
287
|
+
function workspaceManifests(root) {
|
|
288
|
+
const cached = WORKSPACE_MANIFESTS.get(root);
|
|
289
|
+
if (cached)
|
|
290
|
+
return cached;
|
|
291
|
+
const manifests = [];
|
|
292
|
+
const visit = (dir) => {
|
|
293
|
+
let entries;
|
|
294
|
+
try {
|
|
295
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
const path = resolve(dir, entry.name);
|
|
302
|
+
if (entry.isDirectory()) {
|
|
303
|
+
if (!NON_WORKSPACE_DIRS.has(entry.name) && !entry.name.startsWith('.'))
|
|
304
|
+
visit(path);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (entry.name !== 'package.json' || !entry.isFile())
|
|
308
|
+
continue;
|
|
309
|
+
try {
|
|
310
|
+
const pkg = JSON.parse(readFileSync(path, 'utf8'));
|
|
311
|
+
manifests.push({ directory: dir, ...(typeof pkg.name === 'string' ? { name: pkg.name } : {}), scripts: pkg.scripts ?? {} });
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
visit(root);
|
|
319
|
+
WORKSPACE_MANIFESTS.set(root, manifests);
|
|
320
|
+
return manifests;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Finds the selected package script name without treating option values as check names.
|
|
324
|
+
*
|
|
325
|
+
* @param check Configured command whose arguments may select a package script.
|
|
326
|
+
*
|
|
327
|
+
* @return Script name for `run`, `run-script`, or a direct package-manager script command.
|
|
328
|
+
*/
|
|
329
|
+
function scriptNameForInvocation(check) {
|
|
330
|
+
const args = check.args ?? [];
|
|
331
|
+
const runIdx = args.findIndex((arg) => arg === 'run' || arg === 'run-script');
|
|
332
|
+
if (runIdx >= 0)
|
|
333
|
+
return args[runIdx + 1] ?? '';
|
|
334
|
+
if (!/^(?:pnpm|npm|yarn|bun)(?:\.cmd)?$/iu.test(basename(check.command)))
|
|
335
|
+
return '';
|
|
336
|
+
for (let idx = 0; idx < args.length; idx++) {
|
|
337
|
+
const arg = args[idx];
|
|
338
|
+
if (arg === '--filter') {
|
|
339
|
+
idx++;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (arg.startsWith('--')) {
|
|
343
|
+
if (arg === '--workspace-concurrency' || arg === '--reporter' || arg === '--dir')
|
|
344
|
+
idx++;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (arg.startsWith('-'))
|
|
348
|
+
continue;
|
|
349
|
+
return arg;
|
|
350
|
+
}
|
|
351
|
+
return '';
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Returns repository package filters supplied to a package-manager command.
|
|
355
|
+
*
|
|
356
|
+
* @param check Configured command whose arguments may select workspace packages.
|
|
357
|
+
*
|
|
358
|
+
* @return Explicit `--filter` selectors in argument order.
|
|
359
|
+
*/
|
|
360
|
+
function workspaceFilters(check) {
|
|
361
|
+
const args = check.args ?? [];
|
|
362
|
+
const selectors = [];
|
|
363
|
+
for (let idx = 0; idx < args.length; idx++) {
|
|
364
|
+
const arg = args[idx];
|
|
365
|
+
if (arg === '--filter' && args[idx + 1])
|
|
366
|
+
selectors.push(args[++idx]);
|
|
367
|
+
else if (arg.startsWith('--filter='))
|
|
368
|
+
selectors.push(arg.slice('--filter='.length));
|
|
369
|
+
}
|
|
370
|
+
return selectors;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Reads package scripts selected by a direct or filtered package-manager command.
|
|
374
|
+
*
|
|
375
|
+
* @param cfg Quality configuration containing the repository root.
|
|
376
|
+
* @param check Configured command whose script may identify its test runner or phase.
|
|
377
|
+
*
|
|
378
|
+
* @return Script text from the root package or selected workspace packages.
|
|
379
|
+
*/
|
|
380
|
+
function scriptsForInvocation(cfg, check) {
|
|
381
|
+
const scriptName = scriptNameForInvocation(check);
|
|
382
|
+
if (!scriptName)
|
|
383
|
+
return [];
|
|
384
|
+
const selectors = workspaceFilters(check);
|
|
385
|
+
const packages = selectors.length
|
|
386
|
+
? workspaceManifests(cfg.root).filter((manifest) => selectors.some((selector) => matchesWorkspaceFilter(cfg.root, selector, manifest)))
|
|
387
|
+
: workspaceManifests(cfg.root).filter((manifest) => manifest.directory === cfg.root);
|
|
388
|
+
return packages.map((manifest) => manifest.scripts[scriptName]).filter((script) => typeof script === 'string');
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Matches an exact workspace name or a simple repository-relative package filter.
|
|
392
|
+
*
|
|
393
|
+
* @param root Repository root that owns workspace package paths.
|
|
394
|
+
* @param selector Package name or path expression following `--filter`.
|
|
395
|
+
* @param manifest Candidate workspace package metadata.
|
|
396
|
+
*
|
|
397
|
+
* @return Whether the selector can be matched to this package without guessing.
|
|
398
|
+
*/
|
|
399
|
+
function matchesWorkspaceFilter(root, selector, manifest) {
|
|
400
|
+
const packageSelector = selector.replace(/^\.\.\./u, '').replace(/\.\.\.$/u, '');
|
|
401
|
+
const pathSelector = packageSelector.startsWith('.') || (!packageSelector.startsWith('@') && packageSelector.includes('/'));
|
|
402
|
+
if (!pathSelector)
|
|
403
|
+
return manifest.name === packageSelector;
|
|
404
|
+
const norm = packageSelector.replace(/^\.\//u, '').replace(/\*.*$/u, '').replace(/\/$/u, '');
|
|
405
|
+
const packagePath = resolve(root, norm || '.');
|
|
406
|
+
const hasGlob = /[*?{[]/u.test(packageSelector);
|
|
407
|
+
if (!hasGlob)
|
|
408
|
+
return manifest.directory === packagePath;
|
|
409
|
+
const relativePackage = relative(packagePath, manifest.directory);
|
|
410
|
+
if (relativePackage === '..' || relativePackage.startsWith(`..${sep}`) || isAbsolute(relativePackage))
|
|
411
|
+
return false;
|
|
412
|
+
if (packageSelector.includes('**'))
|
|
413
|
+
return true;
|
|
414
|
+
return !relativePackage.includes(sep);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Reads one display string for the command and its selected package script.
|
|
418
|
+
*
|
|
419
|
+
* @param cfg Quality configuration containing the repository root.
|
|
420
|
+
* @param check Configured command to describe.
|
|
421
|
+
*
|
|
422
|
+
* @return Lowercase command and script text; free-form argument values are excluded.
|
|
423
|
+
*/
|
|
424
|
+
function invocationText(cfg, check) {
|
|
425
|
+
return `${check.name} ${check.command} ${scriptNameForInvocation(check)} ${scriptsForInvocation(cfg, check).join(' ')}`.toLowerCase();
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Identifies test commands without adding grouping metadata to quality.json.
|
|
429
|
+
*
|
|
430
|
+
* @param cfg Quality configuration containing repository commands.
|
|
431
|
+
* @param check Configured command to classify.
|
|
432
|
+
*
|
|
433
|
+
* @return Whether the command selects a test suite.
|
|
434
|
+
*/
|
|
435
|
+
function isTestInvocation(cfg, check) {
|
|
436
|
+
return (testRunner(cfg, check) !== 'unknown' ||
|
|
437
|
+
/\b(?:test|tests|spec|specs|playwright|vitest|jest|mocha|e2e|contract|integration)\b/u.test(invocationText(cfg, check)) ||
|
|
438
|
+
(check.args ?? []).some((arg) => arg === '--test' || arg.startsWith('--test=')));
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Assigns a configured command to the fixed runner phase order.
|
|
442
|
+
*
|
|
443
|
+
* @param cfg Quality configuration containing repository commands.
|
|
444
|
+
* @param check Configured command to classify.
|
|
445
|
+
*
|
|
446
|
+
* @return The output and execution phase selected by Terra Quality.
|
|
447
|
+
*/
|
|
448
|
+
function phaseForInvocation(cfg, check) {
|
|
449
|
+
const text = invocationText(cfg, check);
|
|
450
|
+
if (isTestInvocation(cfg, check))
|
|
451
|
+
return 'Tests';
|
|
452
|
+
if (/\b(?:format|prettier)\b/u.test(text))
|
|
453
|
+
return 'Format';
|
|
454
|
+
if (/\b(?:typecheck|type-check|types|tsc|astro\s+check)\b/u.test(text))
|
|
455
|
+
return 'Framework / type checks';
|
|
456
|
+
if (/\b(?:build|compile|bundle)\b/u.test(text))
|
|
457
|
+
return 'Build';
|
|
458
|
+
if (/\b(?:github|diff|repository-health|repo-health)\b/u.test(text))
|
|
459
|
+
return 'Repository health';
|
|
460
|
+
return 'Static quality';
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Groups selected tests by the kind of boundary they exercise.
|
|
464
|
+
*
|
|
465
|
+
* @param cfg Quality configuration containing repository commands.
|
|
466
|
+
* @param check Configured test command to classify.
|
|
467
|
+
*
|
|
468
|
+
* @return Stable display category for the test suite.
|
|
469
|
+
*/
|
|
470
|
+
function testGroup(cfg, check) {
|
|
471
|
+
const text = invocationText(cfg, check);
|
|
472
|
+
if (testRunner(cfg, check) === 'playwright' || /\b(?:playwright|browser|e2e|visual|cloud)\b/u.test(text))
|
|
473
|
+
return 'Browser / Cloud';
|
|
474
|
+
if (/\b(?:integration|database|postgres|neon|http|api)\b/u.test(text))
|
|
475
|
+
return 'Integration';
|
|
476
|
+
if (/\b(?:unit|contract)\b/u.test(text))
|
|
477
|
+
return 'Unit / Contract';
|
|
478
|
+
return 'Other tests';
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Identifies the runner family for configured test-command reporting.
|
|
482
|
+
*
|
|
483
|
+
* @param cfg Quality configuration containing repository commands.
|
|
484
|
+
* @param check Configured test command to classify.
|
|
485
|
+
*
|
|
486
|
+
* @return Node, Playwright, or unknown when the runner cannot be inferred safely.
|
|
487
|
+
*/
|
|
488
|
+
function testRunner(cfg, check) {
|
|
489
|
+
const text = invocationText(cfg, check);
|
|
490
|
+
const args = check.args ?? [];
|
|
491
|
+
if (BPLAYWRIGHT_B_U_REGEX.test(text) || /\bplaywright\b/iu.test(args.join(' ')))
|
|
492
|
+
return 'playwright';
|
|
493
|
+
const nodeCmd = /^(?:node|node\.exe)$/iu.test(basename(check.command));
|
|
494
|
+
const nodeTestArgs = args.some((arg) => arg === '--test' || arg.startsWith('--test=')) || args.some((arg) => /\.(?:mjs|cjs)$/iu.test(arg));
|
|
495
|
+
if ((nodeCmd && nodeTestArgs) || BWORKSPACE_TEST_RUNNER_JS_B_U_REGEX.test(text) || /\bnode\s+[^\n]*--test\b/u.test(text) || /\bpnpm\s+-r\s+test\b/u.test(text))
|
|
496
|
+
return 'node';
|
|
497
|
+
return 'unknown';
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Removes configured Node test reporters so the shared reporter remains authoritative.
|
|
501
|
+
*
|
|
502
|
+
* @param args Selected Node or package-manager arguments.
|
|
503
|
+
*
|
|
504
|
+
* @return Arguments without CLI reporter selections or their destination values.
|
|
505
|
+
*/
|
|
506
|
+
function withoutNodeReporterArgs(args) {
|
|
507
|
+
const retained = [];
|
|
508
|
+
for (let idx = 0; idx < args.length; idx++) {
|
|
509
|
+
const arg = args[idx];
|
|
510
|
+
if (arg === '--test-reporter' || arg === '--test-reporter-destination') {
|
|
511
|
+
idx++;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (arg.startsWith('--test-reporter=') || arg.startsWith('--test-reporter-destination='))
|
|
515
|
+
continue;
|
|
516
|
+
retained.push(arg);
|
|
517
|
+
}
|
|
518
|
+
return retained;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Removes duplicate configured test invocations while preserving the first selected name.
|
|
522
|
+
*
|
|
523
|
+
* @param checks Configured commands in their source order.
|
|
524
|
+
*
|
|
525
|
+
* @return Commands with repeated executable-and-argument vectors removed.
|
|
526
|
+
*/
|
|
527
|
+
function uniqueTestSuites(checks) {
|
|
528
|
+
const seen = new Set();
|
|
529
|
+
return checks.filter((check) => {
|
|
530
|
+
const key = JSON.stringify([check.command, check.args ?? []]);
|
|
531
|
+
if (seen.has(key))
|
|
532
|
+
return false;
|
|
533
|
+
seen.add(key);
|
|
534
|
+
return true;
|
|
535
|
+
});
|
|
536
|
+
}
|
|
241
537
|
/**
|
|
242
538
|
* Converts tool output into compact single-line messages without terminal control codes.
|
|
243
539
|
*
|
|
@@ -290,6 +586,143 @@ function run(cfg, cmd, args, opts = {}) {
|
|
|
290
586
|
console.error(` ${cmd} failed: ${result.error.message}`);
|
|
291
587
|
return result.status ?? 1;
|
|
292
588
|
}
|
|
589
|
+
/**
|
|
590
|
+
* Runs one selected test command with shared case reporting when its runner is known.
|
|
591
|
+
*
|
|
592
|
+
* @param cfg Quality configuration defining the repository root.
|
|
593
|
+
* @param check Test command selected by quality.json.
|
|
594
|
+
* @param completed One-based completed-suite count.
|
|
595
|
+
* @param total Number of selected, deduplicated suites.
|
|
596
|
+
* @param lastGroup Previously printed test category, if any.
|
|
597
|
+
*
|
|
598
|
+
* @return Updated group label and the command exit status.
|
|
599
|
+
*/
|
|
600
|
+
function runTestSuite(cfg, check, completed, total, lastGroup) {
|
|
601
|
+
const group = testGroup(cfg, check);
|
|
602
|
+
if (group !== lastGroup)
|
|
603
|
+
console.error(`\n ${group}`);
|
|
604
|
+
const runner = testRunner(cfg, check);
|
|
605
|
+
const args = [...(check.args ?? [])];
|
|
606
|
+
const env = { ...process.env };
|
|
607
|
+
let inheritOutput = runner !== 'unknown';
|
|
608
|
+
if (runner === 'node') {
|
|
609
|
+
const reporterUrl = new URL('./node-reporter.js', import.meta.url).href;
|
|
610
|
+
env.NODE_OPTIONS = qualityNodeReporterOptions(env.NODE_OPTIONS, reporterUrl);
|
|
611
|
+
env.TERRA_QUALITY_NODE_REPORTER_OWNER = '1';
|
|
612
|
+
args.splice(0, args.length, ...withoutNodeReporterArgs(args));
|
|
613
|
+
}
|
|
614
|
+
else if (runner === 'playwright') {
|
|
615
|
+
const reporterPath = resolve(dirname(fileURLToPath(import.meta.url)), 'playwright-reporter.js');
|
|
616
|
+
const reporterArg = `--reporter=${reporterPath}`;
|
|
617
|
+
const selectedArgs = [];
|
|
618
|
+
for (let idx = 0; idx < args.length; idx++) {
|
|
619
|
+
const arg = args[idx];
|
|
620
|
+
if (arg === '--reporter') {
|
|
621
|
+
idx++;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (!arg.startsWith('--reporter='))
|
|
625
|
+
selectedArgs.push(arg);
|
|
626
|
+
}
|
|
627
|
+
args.splice(0, args.length, ...selectedArgs);
|
|
628
|
+
const packageScript = /\b(?:pnpm|npm|yarn)\b/u.test(check.command) && args.some((arg) => arg === 'run' || arg === 'run-script');
|
|
629
|
+
if (packageScript) {
|
|
630
|
+
if (!args.includes('--'))
|
|
631
|
+
args.push('--');
|
|
632
|
+
args.push(reporterArg);
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
const separator = args.indexOf('--');
|
|
636
|
+
if (separator < 0)
|
|
637
|
+
args.push(reporterArg);
|
|
638
|
+
else
|
|
639
|
+
args.splice(separator, 0, reporterArg);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
inheritOutput = false;
|
|
644
|
+
}
|
|
645
|
+
qualityHeader(`command:${check.name}`, `Running ${check.command} ${(check.args ?? []).join(' ')}`.trim());
|
|
646
|
+
const result = spawnSync(check.command, args, { cwd: cfg.root, stdio: inheritOutput ? 'inherit' : ['inherit', 'pipe', 'pipe'], env, maxBuffer: TOOL_OUTPUT_MAX_BUFFER });
|
|
647
|
+
if (!inheritOutput) {
|
|
648
|
+
printChildOutput(result.stdout);
|
|
649
|
+
printChildOutput(result.stderr);
|
|
650
|
+
}
|
|
651
|
+
if (result.error)
|
|
652
|
+
console.error(` ${check.command} failed: ${result.error.message}`);
|
|
653
|
+
const status = result.status ?? 1;
|
|
654
|
+
if (reportStages && reportStageName) {
|
|
655
|
+
const stage = reportStages.get(reportStageName);
|
|
656
|
+
if (stage && status)
|
|
657
|
+
stage.status = 'failed';
|
|
658
|
+
}
|
|
659
|
+
if (status)
|
|
660
|
+
process.exitCode = status;
|
|
661
|
+
console.error(` ${status ? '✗ failed' : '✓ passed'}`);
|
|
662
|
+
qualityProgress('Test suites', completed, total);
|
|
663
|
+
return { group, status };
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Runs configured checks assigned to one fixed phase.
|
|
667
|
+
*
|
|
668
|
+
* @param cfg Quality configuration containing selected repository commands.
|
|
669
|
+
* @param phase Fixed Quality runner phase to execute.
|
|
670
|
+
*
|
|
671
|
+
* @return Number of commands that failed in this phase.
|
|
672
|
+
*/
|
|
673
|
+
function runQualityPhase(cfg, phase) {
|
|
674
|
+
const cmds = (cfg.qualityCommands ?? []).filter((check) => qualityCmdEnabled(cfg, check.name) && phaseForInvocation(cfg, check) === phase);
|
|
675
|
+
let failures = 0;
|
|
676
|
+
let lastGroup;
|
|
677
|
+
if (phase === 'Tests') {
|
|
678
|
+
const suites = uniqueTestSuites(cmds);
|
|
679
|
+
if (!suites.length) {
|
|
680
|
+
console.error(' No configured test suites.');
|
|
681
|
+
return 0;
|
|
682
|
+
}
|
|
683
|
+
const groupOrder = ['Unit / Contract', 'Integration', 'Browser / Cloud', 'Other tests'];
|
|
684
|
+
suites.sort((left, right) => groupOrder.indexOf(testGroup(cfg, left)) - groupOrder.indexOf(testGroup(cfg, right)));
|
|
685
|
+
qualityProgress('Test suites', 0, suites.length);
|
|
686
|
+
console.error('');
|
|
687
|
+
for (const [idx, check] of suites.entries()) {
|
|
688
|
+
const result = runTestSuite(cfg, check, idx + 1, suites.length, lastGroup);
|
|
689
|
+
lastGroup = result.group;
|
|
690
|
+
if (result.status)
|
|
691
|
+
failures += 1;
|
|
692
|
+
}
|
|
693
|
+
return failures;
|
|
694
|
+
}
|
|
695
|
+
for (const check of cmds) {
|
|
696
|
+
qualityHeader(`command:${check.name}`, `Running ${check.command} ${(check.args ?? []).join(' ')}`.trim());
|
|
697
|
+
const status = run(cfg, check.command, check.args ?? []);
|
|
698
|
+
if (status) {
|
|
699
|
+
failures += 1;
|
|
700
|
+
process.exitCode = status;
|
|
701
|
+
}
|
|
702
|
+
console.error(` ${status ? '✗ failed' : '✓ passed'}`);
|
|
703
|
+
}
|
|
704
|
+
return failures;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Identifies an exact configured Git whitespace check.
|
|
708
|
+
*
|
|
709
|
+
* @param check Configured repository-health command.
|
|
710
|
+
*
|
|
711
|
+
* @return Whether the invocation duplicates the built-in `git diff --check`.
|
|
712
|
+
*/
|
|
713
|
+
function isGitDiffCheck(check) {
|
|
714
|
+
return basename(check.command).toLowerCase() === 'git' && check.args?.length === 2 && check.args[0] === 'diff' && check.args[1] === '--check';
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Detects whether an enabled repository-health command already performs Git's diff check.
|
|
718
|
+
*
|
|
719
|
+
* @param cfg Normalized repository configuration.
|
|
720
|
+
*
|
|
721
|
+
* @return Whether a selected exact Git diff check is present.
|
|
722
|
+
*/
|
|
723
|
+
function hasCfgdGitDiffCheck(cfg) {
|
|
724
|
+
return (cfg.qualityCommands ?? []).some((check) => qualityCmdEnabled(cfg, check.name) && isGitDiffCheck(check));
|
|
725
|
+
}
|
|
293
726
|
/**
|
|
294
727
|
* Captures source text so automatic tools can report changed-file counts without
|
|
295
728
|
* replaying their successful per-file output.
|
|
@@ -823,18 +1256,6 @@ function runCfgdCmd(cfg, name) {
|
|
|
823
1256
|
process.exitCode = status;
|
|
824
1257
|
console.error(` ${status ? '✗ failed' : '✓ passed'}`);
|
|
825
1258
|
}
|
|
826
|
-
/**
|
|
827
|
-
* Runs every enabled configured quality command once.
|
|
828
|
-
*
|
|
829
|
-
* @param cfg Quality configuration containing repository commands.
|
|
830
|
-
*
|
|
831
|
-
* @return Nothing; command failures are reflected in the process exit code.
|
|
832
|
-
*/
|
|
833
|
-
function runCfgdCmds(cfg) {
|
|
834
|
-
for (const check of cfg.qualityCommands ?? [])
|
|
835
|
-
if (qualityCmdEnabled(cfg, check.name))
|
|
836
|
-
runCfgdCmd(cfg, check.name);
|
|
837
|
-
}
|
|
838
1259
|
/**
|
|
839
1260
|
* Runs configured advisory checks after Knip without turning recommendations into failures.
|
|
840
1261
|
*
|
|
@@ -1383,7 +1804,7 @@ function printDeadCodeFindings(findings, manualWarnings, typeSafetyVerified = tr
|
|
|
1383
1804
|
const errs = findings.filter((finding) => finding.severity === 'error' || manualWarnings === 'error');
|
|
1384
1805
|
const warnings = findings.filter((finding) => finding.severity === 'warning' && manualWarnings !== 'error');
|
|
1385
1806
|
const manualCount = findings.filter((finding) => finding.severity === 'warning').length;
|
|
1386
|
-
for (const finding of findings) {
|
|
1807
|
+
for (const finding of [...findings].sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.rule.localeCompare(right.rule) || left.name.localeCompare(right.name))) {
|
|
1387
1808
|
const severity = finding.severity === 'warning' && manualWarnings === 'error' ? 'ERROR' : finding.severity.toUpperCase();
|
|
1388
1809
|
console.error(` - [${severity}] ${finding.rule}: ${finding.file}:${finding.line} ${finding.name}`);
|
|
1389
1810
|
console.error(` Reason: ${finding.reason}`);
|
|
@@ -1393,7 +1814,7 @@ function printDeadCodeFindings(findings, manualWarnings, typeSafetyVerified = tr
|
|
|
1393
1814
|
}
|
|
1394
1815
|
console.error(` ${errs.length} error${errs.length === 1 ? '' : 's'}; ${manualCount} manual finding${manualCount === 1 ? '' : 's'}${manualWarnings === 'warning' ? ` (${warnings.length} warning${warnings.length === 1 ? '' : 's'})` : ''}.`);
|
|
1395
1816
|
if (manualWarnings === 'error' && manualCount > 0)
|
|
1396
|
-
console.error('
|
|
1817
|
+
console.error(' Resolve each manual finding one by one, in the order listed, before committing.');
|
|
1397
1818
|
if (errs.length)
|
|
1398
1819
|
process.exitCode = 1;
|
|
1399
1820
|
}
|
|
@@ -1411,6 +1832,14 @@ function printDeadCodeFindings(findings, manualWarnings, typeSafetyVerified = tr
|
|
|
1411
1832
|
function runDeadCode(cfg, fix, all, verify = true, emit = true) {
|
|
1412
1833
|
if (emit)
|
|
1413
1834
|
qualityHeader('dead-code', `${fix ? 'Fixing safe cases and checking' : 'Read-only check of'} unused files, exports, declarations, and single-use code`);
|
|
1835
|
+
let verificationStarted = false;
|
|
1836
|
+
const beginVerification = () => {
|
|
1837
|
+
if (verificationStarted)
|
|
1838
|
+
return;
|
|
1839
|
+
verificationStarted = true;
|
|
1840
|
+
if (emit)
|
|
1841
|
+
qualityHeader('dead-code/verification', 'Verify remaining unused files, exports, declarations, and single-use code');
|
|
1842
|
+
};
|
|
1414
1843
|
const changedFiles = new Set();
|
|
1415
1844
|
const srcSnapshot = fix ? new Map(filesFor(cfg, true).map((f) => [f, readFileSync(f, 'utf8')])) : new Map();
|
|
1416
1845
|
const initialInventory = fix ? new Set(srcSnapshot.keys()) : new Set();
|
|
@@ -1420,6 +1849,7 @@ function runDeadCode(cfg, fix, all, verify = true, emit = true) {
|
|
|
1420
1849
|
if (fix && emit)
|
|
1421
1850
|
console.error(' TypeScript baseline ready.');
|
|
1422
1851
|
const typeRejectedFiles = new Set();
|
|
1852
|
+
const singleUseVerificationFindings = [];
|
|
1423
1853
|
const eslintReportsByPath = new Map();
|
|
1424
1854
|
const eslintScannedContents = new Map();
|
|
1425
1855
|
let importedConsumersForRun;
|
|
@@ -1437,6 +1867,8 @@ function runDeadCode(cfg, fix, all, verify = true, emit = true) {
|
|
|
1437
1867
|
return dirtyFiles.length ? scanEslint(dirtyFiles) : [];
|
|
1438
1868
|
};
|
|
1439
1869
|
if (fix) {
|
|
1870
|
+
if (emit)
|
|
1871
|
+
qualityHeader('dead-code/safe-fixes', 'Apply only mechanically proven fixes');
|
|
1440
1872
|
if (qualityCheckEnabled(cfg, 'knip')) {
|
|
1441
1873
|
if (emit)
|
|
1442
1874
|
qualityHeader('dead-code/knip-fix', 'Removing only unambiguous unused export modifiers');
|
|
@@ -1445,7 +1877,7 @@ function runDeadCode(cfg, fix, all, verify = true, emit = true) {
|
|
|
1445
1877
|
importedConsumersForRun = qualityCheckEnabled(cfg, 'knip') || qualityCheckEnabled(cfg, 'lint') ? importedExportConsumers(cfg) : undefined;
|
|
1446
1878
|
if (qualityCheckEnabled(cfg, 'lint')) {
|
|
1447
1879
|
if (emit)
|
|
1448
|
-
|
|
1880
|
+
qualityHeader('dead-code/eslint-fix', 'Scan configured files and apply only safe unused-declaration fixes');
|
|
1449
1881
|
let reportsToFix = scanEslint();
|
|
1450
1882
|
for (let round = 0; round < 5; round++) {
|
|
1451
1883
|
if (!reportsToFix.some((report) => report.messages.some((message) => message.ruleId === 'unused-imports/no-unused-vars')))
|
|
@@ -1461,12 +1893,12 @@ function runDeadCode(cfg, fix, all, verify = true, emit = true) {
|
|
|
1461
1893
|
}
|
|
1462
1894
|
}
|
|
1463
1895
|
if (emit)
|
|
1464
|
-
|
|
1896
|
+
qualityHeader('dead-code/single-use', 'Scan declarations and references, classify candidates, and inline only proven cases');
|
|
1465
1897
|
const singleUseChangedFiles = new Set();
|
|
1466
1898
|
fixSingleUseDeclarations(cfg, singleUseChangedFiles, (pass, filesChanged) => {
|
|
1467
1899
|
if (emit)
|
|
1468
1900
|
console.error(` Single-use rewrite pass ${pass} starting (${filesChanged} source files changed so far).`);
|
|
1469
|
-
});
|
|
1901
|
+
}, singleUseVerificationFindings);
|
|
1470
1902
|
for (const file of singleUseChangedFiles)
|
|
1471
1903
|
changedFiles.add(file);
|
|
1472
1904
|
const currInventory = new Set(filesFor(cfg, true));
|
|
@@ -1484,6 +1916,8 @@ function runDeadCode(cfg, fix, all, verify = true, emit = true) {
|
|
|
1484
1916
|
if (curr !== original)
|
|
1485
1917
|
proposed.set(file, curr);
|
|
1486
1918
|
}
|
|
1919
|
+
if (verify)
|
|
1920
|
+
beginVerification();
|
|
1487
1921
|
if (proposed.size) {
|
|
1488
1922
|
if (!typeProgram)
|
|
1489
1923
|
throw new Error('Dead-code edit validation requires its baseline TypeScript program.');
|
|
@@ -1504,11 +1938,14 @@ function runDeadCode(cfg, fix, all, verify = true, emit = true) {
|
|
|
1504
1938
|
if (readFileSync(file, 'utf8') !== text)
|
|
1505
1939
|
writeFileSync(file, text);
|
|
1506
1940
|
}
|
|
1941
|
+
if (verify)
|
|
1942
|
+
beginVerification();
|
|
1507
1943
|
if (qualityCheckEnabled(cfg, 'lint'))
|
|
1508
1944
|
scanDirtyEslintFiles();
|
|
1509
1945
|
}
|
|
1510
1946
|
if (!verify)
|
|
1511
1947
|
return [];
|
|
1948
|
+
beginVerification();
|
|
1512
1949
|
const findings = [];
|
|
1513
1950
|
if (qualityCheckEnabled(cfg, 'knip')) {
|
|
1514
1951
|
if (emit)
|
|
@@ -1520,7 +1957,8 @@ function runDeadCode(cfg, fix, all, verify = true, emit = true) {
|
|
|
1520
1957
|
scanEslint();
|
|
1521
1958
|
findings.push(...unusedDeclarationFindings(cfg, [...eslintReportsByPath.values()]));
|
|
1522
1959
|
}
|
|
1523
|
-
|
|
1960
|
+
const finalSingleUseFindings = fix && !typeRejectedFiles.size ? singleUseVerificationFindings : singleUseFindings(cfg);
|
|
1961
|
+
for (const single of finalSingleUseFindings)
|
|
1524
1962
|
findings.push({
|
|
1525
1963
|
rule: 'typescript/single-use',
|
|
1526
1964
|
file: single.file,
|
|
@@ -2030,8 +2468,14 @@ export function runQuality(cfg, strict = false, all = true, analysis) {
|
|
|
2030
2468
|
const duplicateImports = qualityCheckEnabled(cfg, 'import') ? duplicateImportFindings(ctx.files) : [];
|
|
2031
2469
|
if (qualityCheckEnabled(cfg, 'import')) {
|
|
2032
2470
|
qualityHeader('imports', 'Checking local import resolution');
|
|
2033
|
-
|
|
2034
|
-
|
|
2471
|
+
if (!result.imports.length && !duplicateImports.length)
|
|
2472
|
+
console.error(' ✓ clean');
|
|
2473
|
+
else {
|
|
2474
|
+
if (result.imports.length)
|
|
2475
|
+
printFindings(result.imports, (f) => `Unresolved import: ${f.importer} -> ${f.specifier}`);
|
|
2476
|
+
if (duplicateImports.length)
|
|
2477
|
+
printFindings(duplicateImports, (f) => `Duplicate named import: ${f.file}:${f.line} repeats an import from ${f.module}`);
|
|
2478
|
+
}
|
|
2035
2479
|
}
|
|
2036
2480
|
if (qualityCheckEnabled(cfg, 'name')) {
|
|
2037
2481
|
qualityHeader('naming', 'Checking concise, consistent symbol names');
|
|
@@ -2170,6 +2614,107 @@ export function runQualityCli(cfg, args = process.argv.slice(2)) {
|
|
|
2170
2614
|
}
|
|
2171
2615
|
writeReport();
|
|
2172
2616
|
}
|
|
2617
|
+
/**
|
|
2618
|
+
* Runs the full quality matrix in package-owned phase order.
|
|
2619
|
+
*
|
|
2620
|
+
* @param cfg Normalized repository configuration.
|
|
2621
|
+
* @param args Validated command arguments.
|
|
2622
|
+
* @param opts Scope and write controls parsed by the CLI.
|
|
2623
|
+
*
|
|
2624
|
+
* @return A promise only when the asynchronous Tailwind check is selected.
|
|
2625
|
+
*/
|
|
2626
|
+
function runFullQuality(cfg, args, opts) {
|
|
2627
|
+
const fix = (check) => qualityAutofixEnabled(cfg, check, opts.fix);
|
|
2628
|
+
const scoped = scopedCfg(cfg, opts.all);
|
|
2629
|
+
if (opts.fix) {
|
|
2630
|
+
outputPhase = 'PREPARING SAFE FIXES';
|
|
2631
|
+
console.error('\nPREPARING SAFE FIXES');
|
|
2632
|
+
}
|
|
2633
|
+
if (opts.fix && qualityCheckEnabled(cfg, 'name') && fix('name')) {
|
|
2634
|
+
const namingScope = scopedCfg(cfg, opts.all);
|
|
2635
|
+
if (namingFindings(namingScope).length)
|
|
2636
|
+
fixNames(cfg, opts.all);
|
|
2637
|
+
}
|
|
2638
|
+
if (fix('forward') && qualityCheckEnabled(cfg, 'forward')) {
|
|
2639
|
+
const result = fixObjectForwarding(scoped);
|
|
2640
|
+
if (result.fixed || result.skipped)
|
|
2641
|
+
console.info(`Forwarding fixer: ${result.fixed} exact duplicates removed${result.skipped ? `; ${result.skipped} advisory cases retained` : ''}.`);
|
|
2642
|
+
}
|
|
2643
|
+
if (fix('repeatedClasses') && qualityCheckEnabled(cfg, 'repeated-classes')) {
|
|
2644
|
+
const result = fixRepeatedClasses(scoped);
|
|
2645
|
+
if (result.fixed || result.skipped)
|
|
2646
|
+
console.info(`Class fixer: ${result.fixed} attributes replaced${result.skipped ? `; ${result.skipped} advisory cases retained` : ''}.`);
|
|
2647
|
+
}
|
|
2648
|
+
if (fix('regexDedup') && qualityCheckEnabled(cfg, 'regex-dedup')) {
|
|
2649
|
+
const result = fixRegexDedup(scoped);
|
|
2650
|
+
if (result.fixed || result.skipped)
|
|
2651
|
+
console.info(`Regex fixer: ${result.fixed} literals replaced${result.skipped ? `; ${result.skipped} advisory cases retained` : ''}.`);
|
|
2652
|
+
}
|
|
2653
|
+
if (fix('lint') && qualityCheckEnabled(cfg, 'lint'))
|
|
2654
|
+
runLint(cfg, { ...opts, fix: true }, true, true);
|
|
2655
|
+
if (opts.fix && qualityCheckEnabled(cfg, 'alias'))
|
|
2656
|
+
aliasFindings(scoped, true);
|
|
2657
|
+
if (qualityCheckEnabled(cfg, 'document')) {
|
|
2658
|
+
const documentationIssues = documentationFindings(scopedCfg(cfg, opts.all)).filter((finding) => finding.severity === 'error');
|
|
2659
|
+
if (documentationIssues.length)
|
|
2660
|
+
runDocumentation(cfg, opts, ['document', ...new Set(documentationIssues.map((finding) => resolve(cfg.root, finding.file))), '--quiet'], true);
|
|
2661
|
+
}
|
|
2662
|
+
if (opts.fix && qualityCheckEnabled(cfg, 'import') && qualityAutofixEnabled(cfg, 'document', opts.fix)) {
|
|
2663
|
+
const repeated = duplicateImportFindings(filesFor(cfg, opts.all));
|
|
2664
|
+
for (const file of new Set(repeated.map((finding) => finding.file)))
|
|
2665
|
+
maintainSrcFile(cfg.root, file);
|
|
2666
|
+
}
|
|
2667
|
+
outputPhase = undefined;
|
|
2668
|
+
beginQualityPhase('Format');
|
|
2669
|
+
if (qualityCheckEnabled(cfg, 'format'))
|
|
2670
|
+
runFormatter(cfg, { ...opts, fix: fix('format') }, opts.all ? [] : filesFor(cfg, false));
|
|
2671
|
+
if (!opts.skipCommands)
|
|
2672
|
+
runQualityPhase(cfg, 'Format');
|
|
2673
|
+
beginQualityPhase('Static quality');
|
|
2674
|
+
const continueStaticAndLater = () => {
|
|
2675
|
+
if (qualityCheckEnabled(cfg, 'dead-code'))
|
|
2676
|
+
runDeadCodeCmd(cfg, qualityAutofixEnabled(cfg, 'dead-code', opts.fix), opts.all);
|
|
2677
|
+
if (qualityCheckEnabled(cfg, 'lint'))
|
|
2678
|
+
runLint(cfg, { ...opts, fix: false }, true, true);
|
|
2679
|
+
const analysis = createQualityAnalysis(scoped);
|
|
2680
|
+
runGenericChecks(scoped, analysis);
|
|
2681
|
+
runQuality(cfg, opts.strict, opts.all, analysis);
|
|
2682
|
+
if (!opts.skipCommands)
|
|
2683
|
+
runQualityPhase(cfg, 'Static quality');
|
|
2684
|
+
if (!opts.skipRecommendations)
|
|
2685
|
+
runRecommendationCmds(cfg);
|
|
2686
|
+
beginQualityPhase('Tests');
|
|
2687
|
+
const testFailures = opts.skipCommands ? 0 : runQualityPhase(cfg, 'Tests');
|
|
2688
|
+
const testSuites = opts.skipCommands ? 0 : uniqueTestSuites((cfg.qualityCommands ?? []).filter((check) => qualityCmdEnabled(cfg, check.name) && phaseForInvocation(cfg, check) === 'Tests')).length;
|
|
2689
|
+
beginQualityPhase('Framework / type checks');
|
|
2690
|
+
if (qualityCheckEnabled(cfg, 'type') && (cfg.tools?.tsc || cfg.typecheckCommand)) {
|
|
2691
|
+
runTypecheck(cfg);
|
|
2692
|
+
if (cfg.typecheckExtensions?.length && !cfg.typecheckRoots?.length && !cfg.typecheckCommand)
|
|
2693
|
+
runProjectTypecheck(cfg);
|
|
2694
|
+
}
|
|
2695
|
+
if (!opts.skipCommands)
|
|
2696
|
+
runQualityPhase(cfg, 'Framework / type checks');
|
|
2697
|
+
beginQualityPhase('Build');
|
|
2698
|
+
if (!opts.skipCommands)
|
|
2699
|
+
runQualityPhase(cfg, 'Build');
|
|
2700
|
+
beginQualityPhase('Repository health');
|
|
2701
|
+
if (!opts.skipCommands)
|
|
2702
|
+
runQualityPhase(cfg, 'Repository health');
|
|
2703
|
+
if (qualityCheckEnabled(cfg, 'diff') && !hasCfgdGitDiffCheck(cfg))
|
|
2704
|
+
runDiffCheck(cfg);
|
|
2705
|
+
if (qualityCheckEnabled(cfg, 'github') && cfg.github?.repositories?.length)
|
|
2706
|
+
runGitHubCheck(cfg, [], false, args.includes('--warn'));
|
|
2707
|
+
console.error(`\nSUMMARY checks complete · ${testSuites} suites · ${testFailures} failed ${testFailures === 1 ? 'suite' : 'suites'} · ${process.exitCode ? 'checks failed' : 'all checks passed'}`);
|
|
2708
|
+
outputPhase = undefined;
|
|
2709
|
+
};
|
|
2710
|
+
if (cfg.tailwindCanonical && qualityCheckEnabled(cfg, 'tailwind-canonical'))
|
|
2711
|
+
return runTailwindCanonicalCheck(cfg, fix('tailwindCanonical')).then(continueStaticAndLater, (error) => {
|
|
2712
|
+
process.exitCode = 1;
|
|
2713
|
+
console.error(` Tailwind canonical check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2714
|
+
continueStaticAndLater();
|
|
2715
|
+
});
|
|
2716
|
+
continueStaticAndLater();
|
|
2717
|
+
}
|
|
2173
2718
|
/**
|
|
2174
2719
|
* Route validated arguments to the requested quality command.
|
|
2175
2720
|
*
|
|
@@ -2187,7 +2732,7 @@ function dispatchQualityCli(cfg, args) {
|
|
|
2187
2732
|
}
|
|
2188
2733
|
if (args.includes('--help') || args.includes('-h')) {
|
|
2189
2734
|
console.info('Usage: terra-quality <command> [options]');
|
|
2190
|
-
console.info('Commands include quality, history, github, document, format, lint, knip, types, single-use, dead-code, tailwind-canonical, security, and domain.');
|
|
2735
|
+
console.info('Commands include quality, history, github, document, format, lint, knip, types, single-use, dead-code, tailwind-canonical, security, ui-recommendations, and domain.');
|
|
2191
2736
|
console.info('Options: --all, --warn, --strict, --skip-commands, --skip-recommendations, --report-json <path>, --repository owner/name; history also accepts --branch <name> and --base-sha <sha>.');
|
|
2192
2737
|
return;
|
|
2193
2738
|
}
|
|
@@ -2200,7 +2745,10 @@ function dispatchQualityCli(cfg, args) {
|
|
|
2200
2745
|
skipRecommendations: args.includes('--skip-recommendations'),
|
|
2201
2746
|
};
|
|
2202
2747
|
const fix = (check) => qualityAutofixEnabled(cfg, check, opts.fix);
|
|
2203
|
-
if (cfg.tailwindCanonical &&
|
|
2748
|
+
if (cfg.tailwindCanonical &&
|
|
2749
|
+
qualityCheckEnabled(cfg, 'tailwind-canonical') &&
|
|
2750
|
+
!['quality', 'quality:all', 'quality:baseline'].includes(cmd) &&
|
|
2751
|
+
['imports', 'quality:patterns', 'types:architecture'].includes(cmd))
|
|
2204
2752
|
return runTailwindCanonicalCheck(cfg, fix('tailwindCanonical')).then(() => {
|
|
2205
2753
|
const checks = { ...cfg.enabledChecks, 'tailwind-canonical': false };
|
|
2206
2754
|
return dispatchQualityCli({ ...cfg, enabledChecks: checks }, args);
|
|
@@ -2285,6 +2833,13 @@ function dispatchQualityCli(cfg, args) {
|
|
|
2285
2833
|
process.exitCode = 1;
|
|
2286
2834
|
return;
|
|
2287
2835
|
}
|
|
2836
|
+
if (cmd === 'ui-recommendations') {
|
|
2837
|
+
const findings = uiComponentRecommendations(cfg);
|
|
2838
|
+
console.info(`UI component recommendations (${findings.length})`);
|
|
2839
|
+
for (const finding of findings)
|
|
2840
|
+
console.info(` ${finding.file}:${finding.line}:${finding.column} <${finding.element}> → <${finding.component}> (import from '${finding.import}') — ${finding.reason}`);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2288
2843
|
if (cfg.qualityCommands?.some((c) => c.name === cmd)) {
|
|
2289
2844
|
runCfgdCmd(cfg, cmd);
|
|
2290
2845
|
return;
|
|
@@ -2307,69 +2862,8 @@ function dispatchQualityCli(cfg, args) {
|
|
|
2307
2862
|
runQuality(cfg, opts.strict, opts.all);
|
|
2308
2863
|
return;
|
|
2309
2864
|
}
|
|
2310
|
-
if (cmd === 'quality' || cmd === 'quality:all' || cmd === 'quality:baseline')
|
|
2311
|
-
|
|
2312
|
-
aliasFindings(cfg, true);
|
|
2313
|
-
if (fix('name') && qualityCheckEnabled(cfg, 'name')) {
|
|
2314
|
-
const namingScope = scopedCfg(cfg, opts.all);
|
|
2315
|
-
if (namingFindings(namingScope).length)
|
|
2316
|
-
fixNames(cfg, opts.all);
|
|
2317
|
-
}
|
|
2318
|
-
const scoped = scopedCfg(cfg, opts.all);
|
|
2319
|
-
if (fix('forward') && qualityCheckEnabled(cfg, 'forward')) {
|
|
2320
|
-
const result = fixObjectForwarding(scoped);
|
|
2321
|
-
if (result.fixed || result.skipped)
|
|
2322
|
-
console.info(`Forwarding fixer: ${result.fixed} exact duplicates removed${result.skipped ? `; ${result.skipped} advisory cases retained` : ''}.`);
|
|
2323
|
-
}
|
|
2324
|
-
if (fix('repeatedClasses') && qualityCheckEnabled(cfg, 'repeated-classes')) {
|
|
2325
|
-
const result = fixRepeatedClasses(scoped);
|
|
2326
|
-
if (result.fixed || result.skipped)
|
|
2327
|
-
console.info(`Class fixer: ${result.fixed} attributes replaced${result.skipped ? `; ${result.skipped} advisory cases retained` : ''}.`);
|
|
2328
|
-
}
|
|
2329
|
-
if (fix('regexDedup') && qualityCheckEnabled(cfg, 'regex-dedup')) {
|
|
2330
|
-
const result = fixRegexDedup(scoped);
|
|
2331
|
-
if (result.fixed || result.skipped)
|
|
2332
|
-
console.info(`Regex fixer: ${result.fixed} literals replaced${result.skipped ? `; ${result.skipped} advisory cases retained` : ''}.`);
|
|
2333
|
-
}
|
|
2334
|
-
if (fix('lint') && qualityCheckEnabled(cfg, 'lint'))
|
|
2335
|
-
runLint(cfg, { ...opts, fix: true }, true, true);
|
|
2336
|
-
if (qualityCheckEnabled(cfg, 'document')) {
|
|
2337
|
-
const documentationIssues = documentationFindings(scopedCfg(cfg, opts.all)).filter((f) => f.severity === 'error');
|
|
2338
|
-
if (documentationIssues.length)
|
|
2339
|
-
runDocumentation(cfg, opts, ['document', ...new Set(documentationIssues.map((f) => resolve(cfg.root, f.file))), '--quiet'], true);
|
|
2340
|
-
}
|
|
2341
|
-
if (fix('format') && qualityCheckEnabled(cfg, 'format'))
|
|
2342
|
-
runFormatter(cfg, { ...opts, fix: true }, opts.all ? [] : filesFor(cfg, false));
|
|
2343
|
-
if (opts.fix && qualityCheckEnabled(cfg, 'alias'))
|
|
2344
|
-
aliasFindings(scoped, true);
|
|
2345
|
-
if (qualityCheckEnabled(cfg, 'dead-code'))
|
|
2346
|
-
runDeadCodeCmd(cfg, qualityAutofixEnabled(cfg, 'dead-code', opts.fix), opts.all);
|
|
2347
|
-
if (qualityCheckEnabled(cfg, 'lint'))
|
|
2348
|
-
runLint(cfg, { ...opts, fix: false }, true, true);
|
|
2349
|
-
const analysis = createQualityAnalysis(scoped);
|
|
2350
|
-
runGenericChecks(scoped, analysis);
|
|
2351
|
-
if (qualityCheckEnabled(cfg, 'type') && cfg.tools?.tsc) {
|
|
2352
|
-
runTypecheck(cfg);
|
|
2353
|
-
if (cfg.typecheckExtensions?.length && !cfg.typecheckRoots?.length)
|
|
2354
|
-
runProjectTypecheck(cfg);
|
|
2355
|
-
}
|
|
2356
|
-
if (!opts.skipCommands)
|
|
2357
|
-
runCfgdCmds(cfg);
|
|
2358
|
-
if (!opts.skipRecommendations)
|
|
2359
|
-
runRecommendationCmds(cfg);
|
|
2360
|
-
if (opts.fix && qualityCheckEnabled(cfg, 'import') && qualityAutofixEnabled(cfg, 'document', opts.fix)) {
|
|
2361
|
-
const repeated = duplicateImportFindings(filesFor(cfg, opts.all));
|
|
2362
|
-
for (const file of new Set(repeated.map((finding) => finding.file)))
|
|
2363
|
-
maintainSrcFile(cfg.root, file);
|
|
2364
|
-
}
|
|
2365
|
-
runQuality(cfg, opts.strict, opts.all, analysis);
|
|
2366
|
-
if (qualityCheckEnabled(cfg, 'diff'))
|
|
2367
|
-
runDiffCheck(cfg);
|
|
2368
|
-
if (opts.fix && qualityCheckEnabled(cfg, 'diff'))
|
|
2369
|
-
runDiffCheck(cfg);
|
|
2370
|
-
if (qualityCheckEnabled(cfg, 'github') && cfg.github?.repositories?.length)
|
|
2371
|
-
runGitHubCheck(cfg, [], false, args.includes('--warn'));
|
|
2372
|
-
}
|
|
2865
|
+
if (cmd === 'quality' || cmd === 'quality:all' || cmd === 'quality:baseline')
|
|
2866
|
+
return runFullQuality(cfg, args, opts);
|
|
2373
2867
|
}
|
|
2374
2868
|
/**
|
|
2375
2869
|
* Loads the repository configuration and runs the CLI in one boundary call.
|