@terra-symposium/standards-quality 0.0.0-stage → 0.6.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 +89 -2
- package/dist/collapse.d.ts +13 -0
- package/dist/collapse.d.ts.map +1 -0
- package/dist/collapse.js +151 -0
- package/dist/collapse.js.map +1 -0
- package/dist/documentation.d.ts +84 -0
- package/dist/documentation.d.ts.map +1 -0
- package/dist/documentation.js +1244 -0
- package/dist/documentation.js.map +1 -0
- package/dist/files.d.ts +39 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +90 -0
- package/dist/files.js.map +1 -0
- package/dist/github-report.d.ts +89 -0
- package/dist/github-report.d.ts.map +1 -0
- package/dist/github-report.js +469 -0
- package/dist/github-report.js.map +1 -0
- package/dist/history-report.d.ts +52 -0
- package/dist/history-report.d.ts.map +1 -0
- package/dist/history-report.js +317 -0
- package/dist/history-report.js.map +1 -0
- package/dist/imports.d.ts +19 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +90 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/inline-if.d.ts +13 -0
- package/dist/inline-if.d.ts.map +1 -0
- package/dist/inline-if.js +48 -0
- package/dist/inline-if.js.map +1 -0
- package/dist/naming.d.ts +148 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/naming.js +673 -0
- package/dist/naming.js.map +1 -0
- package/dist/object-forwarding.d.ts +44 -0
- package/dist/object-forwarding.d.ts.map +1 -0
- package/dist/object-forwarding.js +182 -0
- package/dist/object-forwarding.js.map +1 -0
- package/dist/patterns.d.ts +85 -0
- package/dist/patterns.d.ts.map +1 -0
- package/dist/patterns.js +911 -0
- package/dist/patterns.js.map +1 -0
- package/dist/port.d.ts +21 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +81 -0
- package/dist/port.js.map +1 -0
- package/dist/progress.d.ts +16 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +30 -0
- package/dist/progress.js.map +1 -0
- package/dist/runner.d.ts +257 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +2526 -0
- package/dist/runner.js.map +1 -0
- package/dist/security.d.ts +19 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +76 -0
- package/dist/security.js.map +1 -0
- package/dist/single-use.d.ts +60 -0
- package/dist/single-use.d.ts.map +1 -0
- package/dist/single-use.js +1209 -0
- package/dist/single-use.js.map +1 -0
- package/package.json +45 -5
- package/quality.schema.json +131 -0
package/dist/runner.js
ADDED
|
@@ -0,0 +1,2526 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* packages/quality/src/runner.ts
|
|
4
|
+
* CLI boundary that loads configuration and coordinates repository quality checks.
|
|
5
|
+
*/
|
|
6
|
+
/*//////////////////////////////////////////////////
|
|
7
|
+
IMPORTS
|
|
8
|
+
//////////////////////////////////////////////////*/
|
|
9
|
+
// API
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
import { aliasFindings, declarationScope, isCallbackDeclaration, isProtectedPath, isRenamableDeclaration, namingFindings, renameDeclaration, renameId } from './naming.js';
|
|
12
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
13
|
+
import { checkImports } from './imports.js';
|
|
14
|
+
import { collapseInlineIfs } from './inline-if.js';
|
|
15
|
+
import { collapseTypesAndObjects } from './collapse.js';
|
|
16
|
+
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { deadCodeEditsIntroduceTypeErrors, deadCodeTypeProgram, fixSingleUseDeclarations, singleUseFindings } from './single-use.js';
|
|
18
|
+
import { documentSrcFile, documentationFindings, duplicateImportFindings, maintainSrcFile, protectLongImports } from './documentation.js';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { buildGitHubReport, formatGitHubReport, hasGitHubAuth } from './github-report.js';
|
|
21
|
+
import { fixObjectForwarding, objectForwardingFindings } from './object-forwarding.js';
|
|
22
|
+
import { fixRegexDedup, fixRepeatedClasses, regexFindings, repeatedClassFindings, runTailwindCanonical, uiPatternFindings } from './patterns.js';
|
|
23
|
+
import { qualityProgress } from './progress.js';
|
|
24
|
+
import { securityFindings } from './security.js';
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { srcFiles, workingSrcFiles } from './files.js';
|
|
27
|
+
import { writeHistoryReport } from './history-report.js';
|
|
28
|
+
// DATA
|
|
29
|
+
import { DOCUMENTATION_DESCRIPTION_NOTICE } from './documentation.js';
|
|
30
|
+
import { PROTECTED_IDENTIFIERS } from './naming.js';
|
|
31
|
+
/*//////////////////////////////////////////////////
|
|
32
|
+
REGEX
|
|
33
|
+
//////////////////////////////////////////////////*/
|
|
34
|
+
const R_N_REGEX = /\r?\n/;
|
|
35
|
+
const SHARED_REGEX = /\/\*|\/\//;
|
|
36
|
+
/*//////////////////////////////////////////////////
|
|
37
|
+
REGEX
|
|
38
|
+
//////////////////////////////////////////////////*/
|
|
39
|
+
const CJS_JS_JSX_MJS_TS_TSX_REGEX = /\.(?:cjs|js|jsx|mjs|ts|tsx)$/;
|
|
40
|
+
const G_REGEX = () => /-/g;
|
|
41
|
+
const C_M_JS_TS_REGEX = /\.(?:c|m)?(?:js|ts)$/;
|
|
42
|
+
const CLI_CMDS = new Set([
|
|
43
|
+
'quality',
|
|
44
|
+
'history',
|
|
45
|
+
'quality:all',
|
|
46
|
+
'quality:baseline',
|
|
47
|
+
'document',
|
|
48
|
+
'maintain',
|
|
49
|
+
'format',
|
|
50
|
+
'naming',
|
|
51
|
+
'domain',
|
|
52
|
+
'single-use',
|
|
53
|
+
'dead-code',
|
|
54
|
+
'security',
|
|
55
|
+
'repeated-classes',
|
|
56
|
+
'tailwind-canonical',
|
|
57
|
+
'regex-dedup',
|
|
58
|
+
'ui-patterns',
|
|
59
|
+
'imports',
|
|
60
|
+
'aliases',
|
|
61
|
+
'forwarding',
|
|
62
|
+
'documentation',
|
|
63
|
+
'github',
|
|
64
|
+
'lint',
|
|
65
|
+
'knip',
|
|
66
|
+
'types',
|
|
67
|
+
]);
|
|
68
|
+
const CLI_FLAGS = new Set(['--all', '--warn', '--strict', '--skip-commands', '--skip-recommendations', '--help', '-h', '--version', '-v', '--report-json', '--branch', '--base-sha', '--repository']);
|
|
69
|
+
const TOOL_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
70
|
+
const ESLINT_BATCH_SIZE = 10;
|
|
71
|
+
const ESLINT_ARGUMENT_BUDGET = 64 * 1024;
|
|
72
|
+
const FOCUSED_ESLINT_FALLBACK = 'TERRA_QUALITY_FOCUSED_ESLINT_UNAVAILABLE';
|
|
73
|
+
const ESLINT_GLOB_SPECIALS = new Set(['*', '?', '[', ']', '{', '}', '(', ')', '!', '\\']);
|
|
74
|
+
let reportStages;
|
|
75
|
+
let reportStageName;
|
|
76
|
+
let reportGitHubData;
|
|
77
|
+
/*//////////////////////////////////////////////////
|
|
78
|
+
REGEX
|
|
79
|
+
//////////////////////////////////////////////////*/
|
|
80
|
+
const C_M_JS_TS_X_REGEX = /\.(?:c|m)?(?:js|ts)x?$/;
|
|
81
|
+
const C_M_JS_TS_X_CLASS_2 = /\.(?:(?:c|m)?(?:js|ts)x?)$/;
|
|
82
|
+
const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g');
|
|
83
|
+
/*//////////////////////////////////////////////////
|
|
84
|
+
HELPERS
|
|
85
|
+
//////////////////////////////////////////////////*/
|
|
86
|
+
/**
|
|
87
|
+
* Creates the shared TypeScript program for the configured source scope.
|
|
88
|
+
*
|
|
89
|
+
* @param files Absolute source paths that should be program roots.
|
|
90
|
+
*
|
|
91
|
+
* @return TypeScript program configured for repository JavaScript and TypeScript analysis.
|
|
92
|
+
*/
|
|
93
|
+
function createAnalysisProgram(files) {
|
|
94
|
+
return ts.createProgram({
|
|
95
|
+
rootNames: files.filter((f) => /\.(?:c|m)?(?:js|jsx|ts|tsx)$/.test(f)),
|
|
96
|
+
options: {
|
|
97
|
+
allowNonTsExtensions: true,
|
|
98
|
+
allowJs: true,
|
|
99
|
+
checkJs: false,
|
|
100
|
+
jsx: ts.JsxEmit.Preserve,
|
|
101
|
+
module: ts.ModuleKind.ESNext,
|
|
102
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
103
|
+
skipLibCheck: true,
|
|
104
|
+
target: ts.ScriptTarget.ESNext,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Builds the per-run source text, AST, program, and checker caches used by static checks.
|
|
110
|
+
*
|
|
111
|
+
* @param cfg Quality configuration defining the source scope.
|
|
112
|
+
*
|
|
113
|
+
* @return Shared analysis context for the configured files.
|
|
114
|
+
*/
|
|
115
|
+
export function createQualityAnalysis(cfg) {
|
|
116
|
+
const files = srcFiles(cfg);
|
|
117
|
+
const program = createAnalysisProgram(files);
|
|
118
|
+
return {
|
|
119
|
+
files,
|
|
120
|
+
texts: new Map(files.map((f) => [f, readFileSync(f, 'utf8')])),
|
|
121
|
+
sources: new Map(files.map((f) => [f, program.getSourceFile(f)]).filter((e) => Boolean(e[1]))),
|
|
122
|
+
program,
|
|
123
|
+
checker: program.getTypeChecker(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Checks whether a built-in quality check is enabled by configuration.
|
|
128
|
+
*
|
|
129
|
+
* @param cfg Quality configuration containing check toggles.
|
|
130
|
+
* @param check Built-in check name to query.
|
|
131
|
+
*
|
|
132
|
+
* @return `true` unless the check is explicitly disabled.
|
|
133
|
+
*/
|
|
134
|
+
export function qualityCheckEnabled(cfg, check) {
|
|
135
|
+
return cfg.enabledChecks?.[check] !== false;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Determines whether a check may apply its configured autofix for the current CLI invocation.
|
|
139
|
+
*
|
|
140
|
+
* @param cfg Quality configuration containing autofix toggles.
|
|
141
|
+
* @param check Autofix-capable check name to query.
|
|
142
|
+
* @param cliFix Whether the invocation allows writes.
|
|
143
|
+
*
|
|
144
|
+
* @return `true` when both the invocation and configuration permit autofix.
|
|
145
|
+
*/
|
|
146
|
+
export function qualityAutofixEnabled(cfg, check, cliFix = true) {
|
|
147
|
+
return cliFix && cfg.autofix?.[check] !== false;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Checks whether a configured repository command is enabled.
|
|
151
|
+
*
|
|
152
|
+
* @param cfg Quality configuration containing command toggles.
|
|
153
|
+
* @param name Configured command name to query.
|
|
154
|
+
*
|
|
155
|
+
* @return `true` when command checks and this command are enabled.
|
|
156
|
+
*/
|
|
157
|
+
export function qualityCmdEnabled(cfg, name) {
|
|
158
|
+
return qualityCheckEnabled(cfg, 'command') && cfg.enabledCommands?.[name] !== false;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Checks whether a configured advisory command is enabled.
|
|
162
|
+
*
|
|
163
|
+
* @param cfg Quality configuration containing recommendation toggles.
|
|
164
|
+
* @param name Recommendation name to query.
|
|
165
|
+
*
|
|
166
|
+
* @return `true` when recommendations and this recommendation are enabled.
|
|
167
|
+
*/
|
|
168
|
+
export function qualityRecommendationEnabled(cfg, name) {
|
|
169
|
+
return qualityCheckEnabled(cfg, 'recommend') && cfg.enabledRecommendations?.[name] !== false;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Loads and normalizes a quality configuration file relative to that file's directory.
|
|
173
|
+
*
|
|
174
|
+
* @param file Configuration path; defaults to `quality.json` in the current directory.
|
|
175
|
+
*
|
|
176
|
+
* @return Normalized quality configuration with absolute repository paths.
|
|
177
|
+
*/
|
|
178
|
+
export function loadQualityCfg(file = resolve(process.cwd(), 'quality.json')) {
|
|
179
|
+
const path = resolve(file);
|
|
180
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
181
|
+
return createQualityCfg({ ...raw, root: raw.root ? resolve(dirname(path), raw.root) : dirname(path) });
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Applies defaults and resolves repository-relative paths in a quality configuration.
|
|
185
|
+
*
|
|
186
|
+
* @param cfg Raw or partially normalized quality configuration.
|
|
187
|
+
*
|
|
188
|
+
* @return Configuration ready for quality checks and subprocess execution.
|
|
189
|
+
*/
|
|
190
|
+
export function createQualityCfg(cfg) {
|
|
191
|
+
const defaultSkipped = ['.git', 'node_modules', 'dist', 'build', 'coverage', 'test-results', '.next', '.astro', '.sanity', '.netlify', '.cache', '.output', '.vercel', '.nuxt', '.husky'];
|
|
192
|
+
return {
|
|
193
|
+
...cfg,
|
|
194
|
+
renameExports: cfg.renameExports ?? false,
|
|
195
|
+
root: resolve(cfg.root),
|
|
196
|
+
sourceRoots: cfg.sourceRoots.map((p) => resolve(cfg.root, p)),
|
|
197
|
+
includeRoots: (cfg.includeRoots ?? []).map((p) => resolve(cfg.root, p)),
|
|
198
|
+
skippedDirectories: [...new Set([...defaultSkipped, ...(cfg.skippedDirectories ?? [])])],
|
|
199
|
+
singleUse: { skippedDirectories: (cfg.singleUse?.skippedDirectories ?? []).map((p) => resolve(cfg.root, p)) },
|
|
200
|
+
skippedFiles: (cfg.skippedFiles ?? []).map((p) => resolve(cfg.root, p)),
|
|
201
|
+
aliases: cfg.aliases ?? {},
|
|
202
|
+
protectedIdentifiers: cfg.protectedIdentifiers ?? [],
|
|
203
|
+
lintRoots: (cfg.lintRoots ?? []).map((p) => resolve(cfg.root, p)),
|
|
204
|
+
typecheckRoots: (cfg.typecheckRoots ?? []).map((p) => resolve(cfg.root, p)),
|
|
205
|
+
typecheckCommand: cfg.typecheckCommand,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Resolves a configured tool name to a repository-local executable when available.
|
|
210
|
+
*
|
|
211
|
+
* @param cfg Quality configuration defining the repository root.
|
|
212
|
+
* @param cfgd Explicit tool path or executable name.
|
|
213
|
+
* @param fallback Default executable name when no tool is configured.
|
|
214
|
+
*
|
|
215
|
+
* @return Executable path or name suitable for spawning.
|
|
216
|
+
*/
|
|
217
|
+
function toolPath(cfg, cfgd, fallback) {
|
|
218
|
+
if (cfgd && (cfgd.includes('/') || cfgd.includes('\\')))
|
|
219
|
+
return cfgd;
|
|
220
|
+
const executable = cfgd ?? fallback;
|
|
221
|
+
const local = resolve(cfg.root, 'node_modules', '.bin', executable);
|
|
222
|
+
return existsSync(local) ? local : executable;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Prints a readable heading for one quality stage.
|
|
226
|
+
*
|
|
227
|
+
* @param name Short name identifying the quality stage.
|
|
228
|
+
* @param detail Human-readable description of the work being performed.
|
|
229
|
+
*
|
|
230
|
+
* @return Nothing; the heading is written to standard error with the other quality messages.
|
|
231
|
+
*/
|
|
232
|
+
function qualityHeader(name, detail) {
|
|
233
|
+
if (reportStages) {
|
|
234
|
+
reportStageName = name;
|
|
235
|
+
reportStages.set(name, { name, status: 'passed', findings: 0 });
|
|
236
|
+
}
|
|
237
|
+
console.error(`\n=== ${name} ===`);
|
|
238
|
+
console.error(detail);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Converts tool output into compact single-line messages without terminal control codes.
|
|
242
|
+
*
|
|
243
|
+
* @param val Raw child-process output.
|
|
244
|
+
*
|
|
245
|
+
* @return Output lines with ANSI codes removed and whitespace normalized.
|
|
246
|
+
*/
|
|
247
|
+
function compactOutput(val) {
|
|
248
|
+
return val
|
|
249
|
+
.replace(ANSI_ESCAPE, '')
|
|
250
|
+
.split(R_N_REGEX)
|
|
251
|
+
.map((l) => l.replace(/\s+/g, ' ').trim())
|
|
252
|
+
.filter(Boolean);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Prints one-line output emitted by a quality subprocess.
|
|
256
|
+
*
|
|
257
|
+
* @param val Raw child-process output.
|
|
258
|
+
*
|
|
259
|
+
* @return Nothing; each non-empty output line is printed once.
|
|
260
|
+
*/
|
|
261
|
+
function printChildOutput(val) {
|
|
262
|
+
if (!val)
|
|
263
|
+
return;
|
|
264
|
+
for (const line of compactOutput(val.toString()))
|
|
265
|
+
console.error(` ${line}`);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Runs one subprocess from the configured repository root and returns its exit status.
|
|
269
|
+
*
|
|
270
|
+
* @param cfg Quality configuration defining the subprocess working directory.
|
|
271
|
+
* @param cmd Executable path or command name.
|
|
272
|
+
* @param args Arguments passed to the executable.
|
|
273
|
+
* @param opts Optional output policy; successful output can be suppressed for autofix passes.
|
|
274
|
+
*
|
|
275
|
+
* @return Child-process exit status, or `1` when spawning fails without a status.
|
|
276
|
+
*/
|
|
277
|
+
function run(cfg, cmd, args, opts = {}) {
|
|
278
|
+
const result = spawnSync(cmd, args, { cwd: cfg.root, stdio: ['inherit', 'pipe', 'pipe'], env: process.env, maxBuffer: TOOL_OUTPUT_MAX_BUFFER });
|
|
279
|
+
if (result.status && reportStages && reportStageName) {
|
|
280
|
+
const stage = reportStages.get(reportStageName);
|
|
281
|
+
if (stage)
|
|
282
|
+
stage.status = 'failed';
|
|
283
|
+
}
|
|
284
|
+
if (!opts.quietSuccess || result.status) {
|
|
285
|
+
printChildOutput(result.stdout);
|
|
286
|
+
printChildOutput(result.stderr);
|
|
287
|
+
}
|
|
288
|
+
if (result.error)
|
|
289
|
+
console.error(` ${cmd} failed: ${result.error.message}`);
|
|
290
|
+
return result.status ?? 1;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Captures source text so automatic tools can report changed-file counts without
|
|
294
|
+
* replaying their successful per-file output.
|
|
295
|
+
*
|
|
296
|
+
* @param cfg Quality configuration defining the repository root.
|
|
297
|
+
* @param files Files modified by the automatic tool.
|
|
298
|
+
*
|
|
299
|
+
* @return Text snapshots keyed by absolute filename.
|
|
300
|
+
*/
|
|
301
|
+
function snapshotFiles(cfg, files) {
|
|
302
|
+
return new Map(files.map((f) => [f, readFileSync(resolve(cfg.root, f), 'utf8')]));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Counts files whose contents differ from a prior snapshot.
|
|
306
|
+
*
|
|
307
|
+
* @param cfg Quality configuration defining the repository root.
|
|
308
|
+
* @param files Files to compare.
|
|
309
|
+
* @param before Initial file contents.
|
|
310
|
+
*
|
|
311
|
+
* @return Number of changed files.
|
|
312
|
+
*/
|
|
313
|
+
function changedFileCount(cfg, files, before) {
|
|
314
|
+
return files.filter((f) => before.get(f) !== readFileSync(resolve(cfg.root, f), 'utf8')).length;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Splits a list into bounded batches for command-line tools.
|
|
318
|
+
*
|
|
319
|
+
* @param vals Values to divide into batches.
|
|
320
|
+
* @param size Maximum number of values per batch.
|
|
321
|
+
*
|
|
322
|
+
* @return Ordered batches covering the input values.
|
|
323
|
+
*/
|
|
324
|
+
function batches(vals, size = 100) {
|
|
325
|
+
return Array.from({ length: Math.ceil(vals.length / size) }, (_, i) => vals.slice(i * size, (i + 1) * size));
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Splits file arguments below a conservative process argument-size limit.
|
|
329
|
+
*
|
|
330
|
+
* @param files Absolute file paths passed to ESLint.
|
|
331
|
+
*
|
|
332
|
+
* @return Ordered file batches that fit within the argument budget.
|
|
333
|
+
*/
|
|
334
|
+
function argumentBatches(files) {
|
|
335
|
+
const result = [];
|
|
336
|
+
let batch = [];
|
|
337
|
+
let bytes = 0;
|
|
338
|
+
for (const file of files) {
|
|
339
|
+
const size = Buffer.byteLength(file) + 1;
|
|
340
|
+
if (batch.length && bytes + size > ESLINT_ARGUMENT_BUDGET) {
|
|
341
|
+
result.push(batch);
|
|
342
|
+
batch = [];
|
|
343
|
+
bytes = 0;
|
|
344
|
+
}
|
|
345
|
+
batch.push(file);
|
|
346
|
+
bytes += size;
|
|
347
|
+
}
|
|
348
|
+
if (batch.length)
|
|
349
|
+
result.push(batch);
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Escapes glob syntax in one repository-relative ESLint file pattern.
|
|
354
|
+
*
|
|
355
|
+
* @param path Repository-relative file path.
|
|
356
|
+
*
|
|
357
|
+
* @return Literal ESLint pattern matching only that path.
|
|
358
|
+
*/
|
|
359
|
+
function eslintFilePattern(path) {
|
|
360
|
+
return [...path].map((character) => (ESLINT_GLOB_SPECIALS.has(character) ? `\\${character}` : character)).join('');
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Selects either the complete configured scope or the changed working-tree scope.
|
|
364
|
+
*
|
|
365
|
+
* @param cfg Quality configuration defining source discovery.
|
|
366
|
+
* @param all Whether to include every configured source file.
|
|
367
|
+
*
|
|
368
|
+
* @return Source files for the requested run scope.
|
|
369
|
+
*/
|
|
370
|
+
function filesFor(cfg, all) {
|
|
371
|
+
return all ? srcFiles(cfg) : workingSrcFiles(cfg);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Restricts a configuration to the files selected for a changed-file run.
|
|
375
|
+
*
|
|
376
|
+
* @param cfg Quality configuration to scope.
|
|
377
|
+
* @param all Whether the full source scope should remain unchanged.
|
|
378
|
+
*
|
|
379
|
+
* @return Original configuration for full runs, or a configuration rooted at changed files.
|
|
380
|
+
*/
|
|
381
|
+
function scopedCfg(cfg, all) {
|
|
382
|
+
return all ? cfg : { ...cfg, sourceRoots: filesFor(cfg, false) };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Runs ESLint over the selected files in argument-safe batches.
|
|
386
|
+
*
|
|
387
|
+
* @param cfg Quality configuration defining the repository and ESLint executable.
|
|
388
|
+
* @param opts CLI options controlling scope and autofix.
|
|
389
|
+
* @param exhaustive Whether warnings should fail the run.
|
|
390
|
+
* @param deadCodeCoordinated Whether the canonical dead-code coordinator owns unused-variable reporting.
|
|
391
|
+
*
|
|
392
|
+
* @return Nothing; failures are reflected in the process exit code.
|
|
393
|
+
*/
|
|
394
|
+
function runLint(cfg, opts, exhaustive = false, deadCodeCoordinated = false) {
|
|
395
|
+
const files = filesFor(cfg, opts.all).filter((f) => /\.(?:astro|cjs|js|jsx|mjs|ts|tsx)$/.test(f));
|
|
396
|
+
if (!files.length)
|
|
397
|
+
return;
|
|
398
|
+
qualityHeader('lint', `${opts.fix ? 'Fixing' : 'Checking'} ${files.length} source files with ESLint`);
|
|
399
|
+
const flags = [
|
|
400
|
+
'--cache',
|
|
401
|
+
'--cache-strategy',
|
|
402
|
+
'content',
|
|
403
|
+
'--cache-location',
|
|
404
|
+
'node_modules/.cache/eslint',
|
|
405
|
+
'--no-warn-ignored',
|
|
406
|
+
...(deadCodeCoordinated ? ['--rule', 'unused-imports/no-unused-vars: off'] : []),
|
|
407
|
+
...(exhaustive ? ['--max-warnings=0'] : []),
|
|
408
|
+
...(opts.fix ? ['--fix'] : []),
|
|
409
|
+
];
|
|
410
|
+
let failed = false;
|
|
411
|
+
const before = opts.fix ? snapshotFiles(cfg, files) : undefined;
|
|
412
|
+
let completed = 0;
|
|
413
|
+
qualityProgress('ESLint', completed, files.length);
|
|
414
|
+
for (const filesBatch of batches(files, ESLINT_BATCH_SIZE)) {
|
|
415
|
+
const status = run(cfg, toolPath(cfg, cfg.tools?.eslint, 'eslint'), [...flags, ...filesBatch], { quietSuccess: opts.fix });
|
|
416
|
+
completed += filesBatch.length;
|
|
417
|
+
qualityProgress('ESLint', completed, files.length);
|
|
418
|
+
if (status) {
|
|
419
|
+
failed = true;
|
|
420
|
+
process.exitCode = status;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (before)
|
|
424
|
+
console.error(` fixed ${changedFileCount(cfg, files, before)} files`);
|
|
425
|
+
console.error(` ${failed ? '✗ failed' : '✓ passed'}`);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Removes function declarations that ESLint proves have no references.
|
|
429
|
+
*
|
|
430
|
+
* Knip can safely remove an unused export modifier while leaving the declaration
|
|
431
|
+
* private. This pass completes that mechanical cleanup without guessing about
|
|
432
|
+
* variables whose initializers may have side effects.
|
|
433
|
+
*
|
|
434
|
+
* @param cfg Quality configuration defining the repository and ESLint executable.
|
|
435
|
+
* @param reports ESLint reports containing unused private function declarations.
|
|
436
|
+
* @param changedFiles Set updated with files modified by the fixer.
|
|
437
|
+
* @param importedConsumers Export names referenced by configured source files.
|
|
438
|
+
*
|
|
439
|
+
* @return Number of unused function declarations removed.
|
|
440
|
+
*/
|
|
441
|
+
function fixUnusedDeclarations(cfg, reports, changedFiles, importedConsumers = importedExportConsumers(cfg)) {
|
|
442
|
+
let fixed = 0;
|
|
443
|
+
for (const report of reports) {
|
|
444
|
+
const unusedMsgs = report.messages.filter((message) => message.ruleId === 'unused-imports/no-unused-vars');
|
|
445
|
+
if (!unusedMsgs.length)
|
|
446
|
+
continue;
|
|
447
|
+
const src = readFileSync(report.filePath, 'utf8');
|
|
448
|
+
const ast = ts.createSourceFile(report.filePath, src, ts.ScriptTarget.Latest, true, C_M_JS_TS_X_CLASS_2.test(report.filePath) && report.filePath.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
449
|
+
if (ast.parseDiagnostics.length)
|
|
450
|
+
throw new Error(`ESLint reported unused declarations in unparsable source: ${report.filePath}`);
|
|
451
|
+
const starts = new Set(unusedMsgs.map((message) => ast.getPositionOfLineAndCharacter(message.line - 1, message.column - 1)));
|
|
452
|
+
const edits = [];
|
|
453
|
+
const visit = (node) => {
|
|
454
|
+
let declaration;
|
|
455
|
+
const name = ts.isFunctionDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)
|
|
456
|
+
? node.name?.text
|
|
457
|
+
: ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)
|
|
458
|
+
? node.name.text
|
|
459
|
+
: undefined;
|
|
460
|
+
const externallyConsumed = name ? importedConsumers.get(resolve(report.filePath))?.has(name) : false;
|
|
461
|
+
const isTopLevel = (candidate) => candidate.parent === ast ||
|
|
462
|
+
(ts.isVariableDeclaration(candidate) && ts.isVariableDeclarationList(candidate.parent) && ts.isVariableStatement(candidate.parent.parent) && candidate.parent.parent.parent === ast);
|
|
463
|
+
const isExported = (candidate) => ts.canHaveModifiers(candidate) && Boolean(ts.getModifiers(candidate)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
|
|
464
|
+
if (ts.isFunctionDeclaration(node) && node.name && starts.has(node.name.getStart(ast)) && isTopLevel(node) && !isExported(node) && !externallyConsumed)
|
|
465
|
+
declaration = node;
|
|
466
|
+
if ((ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) && starts.has(node.name.getStart(ast)) && isTopLevel(node) && !isExported(node) && !externallyConsumed)
|
|
467
|
+
declaration = node;
|
|
468
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && starts.has(node.name.getStart(ast)) && isTopLevel(node) && !externallyConsumed) {
|
|
469
|
+
if (ts.isVariableDeclarationList(node.parent) &&
|
|
470
|
+
(node.parent.flags & ts.NodeFlags.Const) !== 0 &&
|
|
471
|
+
node.parent.declarations.length === 1 &&
|
|
472
|
+
ts.isVariableStatement(node.parent.parent) &&
|
|
473
|
+
!node.parent.parent.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) &&
|
|
474
|
+
node.initializer &&
|
|
475
|
+
isPureUnusedInit(node.initializer))
|
|
476
|
+
declaration = node.parent.parent;
|
|
477
|
+
}
|
|
478
|
+
if (declaration) {
|
|
479
|
+
let start = declaration.getStart(ast);
|
|
480
|
+
const comments = ts.getLeadingCommentRanges(src, declaration.getFullStart()) ?? [];
|
|
481
|
+
const attached = comments.at(-1);
|
|
482
|
+
if (attached?.kind === ts.SyntaxKind.MultiLineCommentTrivia && src.slice(attached.end, start).trim() === '' && src.slice(attached.pos, attached.pos + 3) === '/**')
|
|
483
|
+
start = attached.pos;
|
|
484
|
+
let end = declaration.end;
|
|
485
|
+
while (src[end] === ' ' || src[end] === '\t')
|
|
486
|
+
end++;
|
|
487
|
+
if (src.slice(end, end + 2) === '\r\n')
|
|
488
|
+
end += 2;
|
|
489
|
+
else if (src[end] === '\n')
|
|
490
|
+
end++;
|
|
491
|
+
edits.push({ start, end });
|
|
492
|
+
}
|
|
493
|
+
ts.forEachChild(node, visit);
|
|
494
|
+
};
|
|
495
|
+
visit(ast);
|
|
496
|
+
if (!edits.length)
|
|
497
|
+
continue;
|
|
498
|
+
let updated = src;
|
|
499
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
500
|
+
updated = `${updated.slice(0, edit.start)}${updated.slice(edit.end)}`;
|
|
501
|
+
const diagnostics = ts.transpileModule(updated, { fileName: report.filePath, reportDiagnostics: true, compilerOptions: { allowJs: true, jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.ESNext } }).diagnostics ??
|
|
502
|
+
[];
|
|
503
|
+
if (diagnostics.some((d) => d.category === ts.DiagnosticCategory.Error))
|
|
504
|
+
continue;
|
|
505
|
+
writeFileSync(report.filePath, updated);
|
|
506
|
+
changedFiles.add(report.filePath);
|
|
507
|
+
fixed += edits.length;
|
|
508
|
+
}
|
|
509
|
+
return fixed;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Accepts only initializers whose evaluation has no observable effects.
|
|
513
|
+
*
|
|
514
|
+
* @param node Initializer expression to classify.
|
|
515
|
+
*
|
|
516
|
+
* @return Whether evaluating the expression is limited to immutable literal data or a function value.
|
|
517
|
+
*/
|
|
518
|
+
function isPureUnusedInit(node) {
|
|
519
|
+
if (ts.isStringLiteralLike(node) ||
|
|
520
|
+
ts.isNumericLiteral(node) ||
|
|
521
|
+
ts.isBigIntLiteral(node) ||
|
|
522
|
+
ts.isRegularExpressionLiteral(node) ||
|
|
523
|
+
node.kind === ts.SyntaxKind.TrueKeyword ||
|
|
524
|
+
node.kind === ts.SyntaxKind.FalseKeyword ||
|
|
525
|
+
node.kind === ts.SyntaxKind.NullKeyword ||
|
|
526
|
+
ts.isArrowFunction(node) ||
|
|
527
|
+
ts.isFunctionExpression(node))
|
|
528
|
+
return true;
|
|
529
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node))
|
|
530
|
+
return isPureUnusedInit(node.expression);
|
|
531
|
+
if (ts.isArrayLiteralExpression(node))
|
|
532
|
+
return node.elements.every((element) => !ts.isSpreadElement(element) && isPureUnusedInit(element));
|
|
533
|
+
if (ts.isObjectLiteralExpression(node))
|
|
534
|
+
return node.properties.every((property) => ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && isPureUnusedInit(property.initializer));
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Uses the repository's resolved unused-variable rule without evaluating its unrelated lint rules.
|
|
539
|
+
*
|
|
540
|
+
* @param cfg Quality configuration defining the repository and selected ESLint executable.
|
|
541
|
+
* @param flatCfg Resolved flat ESLint config path.
|
|
542
|
+
*
|
|
543
|
+
* @return Whether the local ESLint API matches the configured executable.
|
|
544
|
+
*/
|
|
545
|
+
function canUseFocusedEslint(cfg, flatCfg) {
|
|
546
|
+
if (!flatCfg)
|
|
547
|
+
return false;
|
|
548
|
+
const cfgdTool = cfg.tools?.eslint;
|
|
549
|
+
const localExecutable = resolve(cfg.root, 'node_modules', '.bin', 'eslint');
|
|
550
|
+
if (cfgdTool && cfgdTool !== 'eslint' && resolve(cfg.root, cfgdTool) !== localExecutable)
|
|
551
|
+
return false;
|
|
552
|
+
return existsSync(resolve(cfg.root, 'node_modules', 'eslint', 'package.json'));
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Builds a temporary flat config containing only each selected file's unused-variable rule.
|
|
556
|
+
*
|
|
557
|
+
* @param cfg Quality configuration defining the repository.
|
|
558
|
+
* @param flatCfg Original flat ESLint config used to resolve parser, plugin, and rule settings.
|
|
559
|
+
* @param files Source files passed to the focused detector.
|
|
560
|
+
*
|
|
561
|
+
* @return JavaScript module source for ESLint's temporary config loader.
|
|
562
|
+
*/
|
|
563
|
+
function focusedEslintCfgSrc(cfg, flatCfg, files) {
|
|
564
|
+
const patterns = files.map((f) => eslintFilePattern(relative(cfg.root, f).split(sep).join('/')));
|
|
565
|
+
return `
|
|
566
|
+
import { createRequire } from 'node:module';
|
|
567
|
+
import { resolve } from 'node:path';
|
|
568
|
+
|
|
569
|
+
const root = ${JSON.stringify(cfg.root)};
|
|
570
|
+
const configPath = ${JSON.stringify(flatCfg)};
|
|
571
|
+
const sourceFiles = ${JSON.stringify(files)};
|
|
572
|
+
const filePatterns = ${JSON.stringify(patterns)};
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
const requireFromRepo = createRequire(resolve(root, 'package.json'));
|
|
576
|
+
const { ESLint } = requireFromRepo('eslint');
|
|
577
|
+
const eslint = new ESLint({ cwd: root, overrideConfigFile: configPath, ignore: false });
|
|
578
|
+
const groups = new Map();
|
|
579
|
+
const objectIds = new WeakMap();
|
|
580
|
+
let nextObjectId = 0;
|
|
581
|
+
const objectId = (value) => {
|
|
582
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) return String(value);
|
|
583
|
+
if (!objectIds.has(value)) objectIds.set(value, ++nextObjectId);
|
|
584
|
+
return objectIds.get(value);
|
|
585
|
+
};
|
|
586
|
+
for (const [index, file] of sourceFiles.entries()) {
|
|
587
|
+
const effective = await eslint.calculateConfigForFile(file);
|
|
588
|
+
const languageOptions = effective?.languageOptions;
|
|
589
|
+
if (!languageOptions?.parser) throw new Error('ESLint did not resolve a parser for ' + file);
|
|
590
|
+
const parserOptions = { ...(languageOptions.parserOptions ?? {}) };
|
|
591
|
+
for (const key of ['project', 'projectService', 'program', 'tsconfigRootDir']) delete parserOptions[key];
|
|
592
|
+
const focusedLanguageOptions = { ...languageOptions, parserOptions };
|
|
593
|
+
const rule = effective.rules?.['unused-imports/no-unused-vars'];
|
|
594
|
+
const severity = Array.isArray(rule) ? rule[0] : rule;
|
|
595
|
+
const active = rule !== undefined && severity !== 0 && severity !== 'off';
|
|
596
|
+
const plugin = effective.plugins?.['unused-imports'];
|
|
597
|
+
if (active && !plugin) throw new Error('ESLint resolved the unused-variable rule without its plugin for ' + file);
|
|
598
|
+
const signature = JSON.stringify({
|
|
599
|
+
parser: objectId(languageOptions.parser),
|
|
600
|
+
plugin: objectId(plugin),
|
|
601
|
+
rule: active ? rule : null,
|
|
602
|
+
languageOptions: { ...focusedLanguageOptions, parser: undefined },
|
|
603
|
+
settings: effective.settings,
|
|
604
|
+
});
|
|
605
|
+
const group = groups.get(signature) ?? {
|
|
606
|
+
patterns: [],
|
|
607
|
+
languageOptions: focusedLanguageOptions,
|
|
608
|
+
plugins: plugin ? { 'unused-imports': plugin } : {},
|
|
609
|
+
rules: active ? { 'unused-imports/no-unused-vars': rule } : {},
|
|
610
|
+
settings: effective.settings,
|
|
611
|
+
};
|
|
612
|
+
group.patterns.push(filePatterns[index]);
|
|
613
|
+
groups.set(signature, group);
|
|
614
|
+
}
|
|
615
|
+
const focused = new ESLint({
|
|
616
|
+
cwd: root,
|
|
617
|
+
ignore: false,
|
|
618
|
+
overrideConfigFile: true,
|
|
619
|
+
overrideConfig: [...groups.values()].map((group) => ({
|
|
620
|
+
files: group.patterns,
|
|
621
|
+
languageOptions: group.languageOptions,
|
|
622
|
+
plugins: group.plugins,
|
|
623
|
+
rules: group.rules,
|
|
624
|
+
...(group.settings ? { settings: group.settings } : {}),
|
|
625
|
+
})),
|
|
626
|
+
});
|
|
627
|
+
const results = await focused.lintFiles(sourceFiles);
|
|
628
|
+
const reports = results.map(({ filePath, messages, fatalErrorCount }) => ({
|
|
629
|
+
filePath: resolve(filePath),
|
|
630
|
+
messages: messages.map(({ ruleId, line, column, message }) => ({ ruleId, line, column, message })),
|
|
631
|
+
fatalErrorCount,
|
|
632
|
+
}));
|
|
633
|
+
process.stdout.write(JSON.stringify(reports));
|
|
634
|
+
} catch (error) {
|
|
635
|
+
console.error(${JSON.stringify(FOCUSED_ESLINT_FALLBACK)}, error instanceof Error ? error.stack ?? error.message : String(error));
|
|
636
|
+
process.exitCode = 2;
|
|
637
|
+
}
|
|
638
|
+
`;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Runs the unused-variable detector under the extracted repository rule configuration.
|
|
642
|
+
*
|
|
643
|
+
* @param cfg Quality configuration defining the repository.
|
|
644
|
+
* @param flatCfg Original flat ESLint config used to resolve the detector rule.
|
|
645
|
+
* @param files Source files in this argument-safe batch.
|
|
646
|
+
*
|
|
647
|
+
* @return ESLint process result, or `undefined` when the extraction requires the full-config fallback.
|
|
648
|
+
*/
|
|
649
|
+
function runFocusedEslint(cfg, flatCfg, files) {
|
|
650
|
+
const result = spawnSync(process.execPath, ['--input-type=module'], {
|
|
651
|
+
cwd: cfg.root,
|
|
652
|
+
encoding: 'utf8',
|
|
653
|
+
env: { ...process.env, ESLINT_USE_FLAT_CONFIG: 'true' },
|
|
654
|
+
input: focusedEslintCfgSrc(cfg, flatCfg, files),
|
|
655
|
+
maxBuffer: TOOL_OUTPUT_MAX_BUFFER,
|
|
656
|
+
});
|
|
657
|
+
if (result.status === 2 && result.stderr?.toString().includes(FOCUSED_ESLINT_FALLBACK))
|
|
658
|
+
return undefined;
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Runs ESLint's machine-readable pass once for the selected source files.
|
|
663
|
+
*
|
|
664
|
+
* @param cfg Quality configuration defining source files and the ESLint executable.
|
|
665
|
+
* @param all Whether to inspect every configured source file instead of changed files.
|
|
666
|
+
* @param selectedFiles Optional exact file set to scan incrementally.
|
|
667
|
+
*
|
|
668
|
+
* @return Validated ESLint reports for the selected source files.
|
|
669
|
+
*/
|
|
670
|
+
function collectEslintReports(cfg, all, selectedFiles) {
|
|
671
|
+
const files = (selectedFiles ?? filesFor(cfg, all)).filter((f) => CJS_JS_JSX_MJS_TS_TSX_REGEX.test(f));
|
|
672
|
+
const reports = [];
|
|
673
|
+
const flatCfg = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts', 'eslint.config.mts', 'eslint.config.cts']
|
|
674
|
+
.map((f) => resolve(cfg.root, f))
|
|
675
|
+
.find((f) => existsSync(f));
|
|
676
|
+
const eslintPath = toolPath(cfg, cfg.tools?.eslint, 'eslint');
|
|
677
|
+
const focusedEslint = canUseFocusedEslint(cfg, flatCfg);
|
|
678
|
+
let completed = 0;
|
|
679
|
+
qualityProgress('ESLint dead-code', completed, files.length);
|
|
680
|
+
for (const filesBatch of argumentBatches(files)) {
|
|
681
|
+
const focusedResult = focusedEslint && flatCfg ? runFocusedEslint(cfg, flatCfg, filesBatch) : undefined;
|
|
682
|
+
const result = focusedResult ??
|
|
683
|
+
spawnSync(eslintPath, ['--format', 'json', '--no-ignore', ...(flatCfg ? ['--config', flatCfg] : []), ...filesBatch], {
|
|
684
|
+
cwd: cfg.root,
|
|
685
|
+
encoding: 'utf8',
|
|
686
|
+
env: flatCfg ? { ...process.env, ESLINT_USE_FLAT_CONFIG: 'true' } : process.env,
|
|
687
|
+
maxBuffer: TOOL_OUTPUT_MAX_BUFFER,
|
|
688
|
+
});
|
|
689
|
+
reports.push(...parseEslintReports(result, filesBatch));
|
|
690
|
+
completed += filesBatch.length;
|
|
691
|
+
qualityProgress('ESLint dead-code', completed, files.length);
|
|
692
|
+
}
|
|
693
|
+
return reports;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Parses ESLint JSON and fails closed when the detector cannot provide a trustworthy result.
|
|
697
|
+
*
|
|
698
|
+
* @param result ESLint process result containing JSON reports and an exit status.
|
|
699
|
+
* @param files Absolute paths of every source file requested from ESLint.
|
|
700
|
+
*
|
|
701
|
+
* @return Validated reports covering the complete requested file set.
|
|
702
|
+
*/
|
|
703
|
+
function parseEslintReports(result, files) {
|
|
704
|
+
if (result.error)
|
|
705
|
+
throw new Error(`ESLint dead-code detector failed: ${result.error.message}`);
|
|
706
|
+
if (result.status !== 0 && result.status !== 1)
|
|
707
|
+
throw new Error(`ESLint dead-code detector exited ${result.status ?? 'without a status'} for ${files.length} source files (${files
|
|
708
|
+
.slice(0, 3)
|
|
709
|
+
.map((f) => relative(process.cwd(), f))
|
|
710
|
+
.join(', ')}${files.length > 3 ? ', …' : ''}): ${compactOutput(result.stderr?.toString() ?? '').join(' ')}`);
|
|
711
|
+
try {
|
|
712
|
+
const reports = JSON.parse(result.stdout?.toString() || '');
|
|
713
|
+
if (!Array.isArray(reports))
|
|
714
|
+
throw new Error('expected a JSON array');
|
|
715
|
+
if (reports.some((report) => !report ||
|
|
716
|
+
typeof report.filePath !== 'string' ||
|
|
717
|
+
!Array.isArray(report.messages) ||
|
|
718
|
+
(report.fatalErrorCount ?? 0) > 0 ||
|
|
719
|
+
report.messages.some((message) => !message || typeof message !== 'object' || !Number.isInteger(message.line) || !Number.isInteger(message.column))))
|
|
720
|
+
throw new Error(`report entries must include filePath and well-formed messages: ${JSON.stringify(reports).slice(0, 4000)}`);
|
|
721
|
+
const actual = new Set(reports.map((report) => resolve(report.filePath)));
|
|
722
|
+
const missing = files.map((f) => resolve(f)).filter((f) => !actual.has(f));
|
|
723
|
+
if (missing.length)
|
|
724
|
+
throw new Error(`missing reports for ${missing.join(', ')}`);
|
|
725
|
+
return reports;
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
throw new Error(`ESLint dead-code detector returned invalid JSON for ${files.length} source files: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Reports unused private declarations, classifying only proven-safe removals as autofixable.
|
|
733
|
+
*
|
|
734
|
+
* @param cfg Quality configuration defining the repository and source roots.
|
|
735
|
+
* @param reports Parsed ESLint messages used to identify unused private declarations.
|
|
736
|
+
*
|
|
737
|
+
* @return Findings with only unambiguous, side-effect-free function removals marked fixable.
|
|
738
|
+
*/
|
|
739
|
+
function unusedDeclarationFindings(cfg, reports) {
|
|
740
|
+
const findings = [];
|
|
741
|
+
for (const report of reports) {
|
|
742
|
+
const src = readFileSync(report.filePath, 'utf8');
|
|
743
|
+
const lines = src.split(R_N_REGEX);
|
|
744
|
+
const ast = ts.createSourceFile(report.filePath, src, ts.ScriptTarget.Latest, true, C_M_JS_TS_X_CLASS_2.test(report.filePath) && report.filePath.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
745
|
+
if (ast.parseDiagnostics.length)
|
|
746
|
+
throw new Error(`ESLint reported unused declarations in unparsable source: ${report.filePath}`);
|
|
747
|
+
for (const msg of report.messages.filter((e) => e.ruleId === 'unused-imports/no-unused-vars')) {
|
|
748
|
+
const text = lines[msg.line - 1]?.slice(Math.max(0, msg.column - 1)) ?? '';
|
|
749
|
+
const name = text.match(/^[A-Za-z_$][\w$]*/)?.[0] ?? msg.message?.match(/^'([^']+)'/)?.[1] ?? '<unknown>';
|
|
750
|
+
const pos = ast.getPositionOfLineAndCharacter(msg.line - 1, msg.column - 1);
|
|
751
|
+
let declaration;
|
|
752
|
+
const find = (node) => {
|
|
753
|
+
if (node.name &&
|
|
754
|
+
ts.isIdentifier(node.name) &&
|
|
755
|
+
node.name.getStart(ast) === pos)
|
|
756
|
+
declaration = node;
|
|
757
|
+
ts.forEachChild(node, find);
|
|
758
|
+
};
|
|
759
|
+
find(ast);
|
|
760
|
+
let autofixable = false;
|
|
761
|
+
let reason = 'The ESLint location did not resolve to a supported private declaration.';
|
|
762
|
+
const declarationExported = declaration &&
|
|
763
|
+
((ts.canHaveModifiers(declaration) && Boolean(ts.getModifiers(declaration)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))) ||
|
|
764
|
+
(ts.isVariableDeclaration(declaration) &&
|
|
765
|
+
ts.isVariableDeclarationList(declaration.parent) &&
|
|
766
|
+
ts.isVariableStatement(declaration.parent.parent) &&
|
|
767
|
+
Boolean(declaration.parent.parent.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))));
|
|
768
|
+
const topLevelPrivate = Boolean(declaration &&
|
|
769
|
+
(declaration.parent === ast ||
|
|
770
|
+
(ts.isVariableDeclaration(declaration) && ts.isVariableDeclarationList(declaration.parent) && ts.isVariableStatement(declaration.parent.parent) && declaration.parent.parent.parent === ast)));
|
|
771
|
+
if (declaration && !declarationExported && topLevelPrivate) {
|
|
772
|
+
if (ts.isFunctionDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) {
|
|
773
|
+
autofixable = true;
|
|
774
|
+
reason = 'ESLint confirms this private declaration is unused and removing it has no initializer effects.';
|
|
775
|
+
}
|
|
776
|
+
else if (ts.isVariableDeclaration(declaration)) {
|
|
777
|
+
autofixable =
|
|
778
|
+
ts.isVariableDeclarationList(declaration.parent) &&
|
|
779
|
+
(declaration.parent.flags & ts.NodeFlags.Const) !== 0 &&
|
|
780
|
+
declaration.parent.declarations.length === 1 &&
|
|
781
|
+
ts.isVariableStatement(declaration.parent.parent) &&
|
|
782
|
+
Boolean(declaration.initializer && isPureUnusedInit(declaration.initializer));
|
|
783
|
+
reason = autofixable ? 'The unused const has a side-effect-free initializer.' : 'Removing this binding may change initializer evaluation or declaration semantics.';
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
else if (declaration && !declarationExported) {
|
|
787
|
+
reason = 'This unused binding is nested inside another declaration; removing its enclosing function or component would change behavior.';
|
|
788
|
+
}
|
|
789
|
+
findings.push({
|
|
790
|
+
rule: 'eslint/unused-private-declaration',
|
|
791
|
+
file: relative(cfg.root, report.filePath),
|
|
792
|
+
line: msg.line,
|
|
793
|
+
name,
|
|
794
|
+
severity: autofixable ? 'error' : 'warning',
|
|
795
|
+
autofixable,
|
|
796
|
+
reason,
|
|
797
|
+
agentAction: autofixable
|
|
798
|
+
? 'Run the canonical dead-code autofix; it should remove this declaration and its attached TSDoc without changing other source behavior.'
|
|
799
|
+
: reason.startsWith('This unused binding is nested')
|
|
800
|
+
? 'Remove only this local parameter or binding when appropriate; preserve its enclosing function, component, hook order, closure behavior, and control flow, then run the focused test and typecheck.'
|
|
801
|
+
: 'Inspect the declaration and its initializer. Remove the unused binding only if initializer evaluation and control flow remain equivalent; preserve observable effects, then run the affected focused test and typecheck.',
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return findings;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Runs one named repository quality command.
|
|
809
|
+
*
|
|
810
|
+
* @param cfg Quality configuration containing configured commands.
|
|
811
|
+
* @param name Command name to execute.
|
|
812
|
+
*
|
|
813
|
+
* @return Nothing; command failures are reflected in the process exit code.
|
|
814
|
+
*/
|
|
815
|
+
function runCfgdCmd(cfg, name) {
|
|
816
|
+
if (!qualityCmdEnabled(cfg, name))
|
|
817
|
+
return;
|
|
818
|
+
const check = cfg.qualityCommands?.find((c) => c.name === name);
|
|
819
|
+
if (!check)
|
|
820
|
+
throw new Error(`Unknown quality domain: ${name}`);
|
|
821
|
+
qualityHeader(`command:${name}`, `Running ${check.command} ${(check.args ?? []).join(' ')}`.trim());
|
|
822
|
+
const status = run(cfg, check.command, check.args ?? []);
|
|
823
|
+
if (status)
|
|
824
|
+
process.exitCode = status;
|
|
825
|
+
console.error(` ${status ? '✗ failed' : '✓ passed'}`);
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Runs every enabled configured quality command once.
|
|
829
|
+
*
|
|
830
|
+
* @param cfg Quality configuration containing repository commands.
|
|
831
|
+
*
|
|
832
|
+
* @return Nothing; command failures are reflected in the process exit code.
|
|
833
|
+
*/
|
|
834
|
+
function runCfgdCmds(cfg) {
|
|
835
|
+
for (const check of cfg.qualityCommands ?? [])
|
|
836
|
+
if (qualityCmdEnabled(cfg, check.name))
|
|
837
|
+
runCfgdCmd(cfg, check.name);
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Runs configured advisory checks after Knip without turning recommendations into failures.
|
|
841
|
+
*
|
|
842
|
+
* @param cfg Quality configuration containing advisory commands.
|
|
843
|
+
*
|
|
844
|
+
* @return Nothing; advisory failures do not alter the process exit code.
|
|
845
|
+
*/
|
|
846
|
+
function runRecommendationCmds(cfg) {
|
|
847
|
+
for (const check of cfg.recommendationCommands ?? []) {
|
|
848
|
+
if (!qualityRecommendationEnabled(cfg, check.name))
|
|
849
|
+
continue;
|
|
850
|
+
qualityHeader(`recommendation:${check.name}`, `Running ${check.command} ${(check.args ?? []).join(' ')}`.trim());
|
|
851
|
+
const status = run(cfg, check.command, check.args ?? []);
|
|
852
|
+
console.error(` ${status ? '✗ failed (advisory)' : '✓ passed'}`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Runs the configured project typecheck command or the default TypeScript compiler invocation.
|
|
857
|
+
*
|
|
858
|
+
* @param cfg Quality configuration containing compiler and project settings.
|
|
859
|
+
*
|
|
860
|
+
* @return Nothing; typecheck failures are reflected in the process exit code.
|
|
861
|
+
*/
|
|
862
|
+
function runProjectTypecheck(cfg) {
|
|
863
|
+
qualityHeader('typecheck', 'Checking the configured project type boundary');
|
|
864
|
+
if (!cfg.typecheckCommand) {
|
|
865
|
+
const status = run(cfg, toolPath(cfg, cfg.tools?.tsc, 'tsc'), ['--noEmit', '-p', cfg.tsconfig ?? 'tsconfig.json']);
|
|
866
|
+
if (status)
|
|
867
|
+
process.exitCode = status;
|
|
868
|
+
console.error(` ${status ? '✗ failed' : '✓ passed'}`);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const result = spawnSync(cfg.typecheckCommand.command, cfg.typecheckCommand.args ?? [], { cwd: cfg.root, stdio: ['inherit', 'pipe', 'pipe'], env: process.env, maxBuffer: TOOL_OUTPUT_MAX_BUFFER });
|
|
872
|
+
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
873
|
+
printChildOutput(result.stdout);
|
|
874
|
+
printChildOutput(result.stderr);
|
|
875
|
+
const diagnosticsReported = /\berror\s+ts\(\d+\)/u.test(output) || /-\s+[1-9]\d*\s+errors?\b/u.test(output);
|
|
876
|
+
const status = result.status ?? 1;
|
|
877
|
+
if (status || diagnosticsReported)
|
|
878
|
+
process.exitCode = status || 1;
|
|
879
|
+
console.error(` ${status || diagnosticsReported ? '✗ failed' : '✓ passed'}`);
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Restores a missing local import when the repository has one unambiguous
|
|
883
|
+
* source export for the unresolved identifier. This is deliberately narrow:
|
|
884
|
+
* unresolved locals, globals, and symbols with multiple possible sources are
|
|
885
|
+
* left for the compiler rather than guessed at.
|
|
886
|
+
*
|
|
887
|
+
* @param cfg Quality configuration defining the repository root and source scope.
|
|
888
|
+
* @param program TypeScript program whose exports and unresolved identifiers are inspected.
|
|
889
|
+
* @param checker TypeScript type checker used to resolve exported symbols.
|
|
890
|
+
*
|
|
891
|
+
* @return Nothing; unambiguous missing local imports are written into source files.
|
|
892
|
+
*/
|
|
893
|
+
function repairMissingImports(cfg, program, checker) {
|
|
894
|
+
const candidates = new Map();
|
|
895
|
+
for (const source of program.getSourceFiles()) {
|
|
896
|
+
if (!source.fileName.startsWith(`${cfg.root}${sep}`) || !C_M_JS_TS_X_REGEX.test(source.fileName))
|
|
897
|
+
continue;
|
|
898
|
+
const module = checker.getSymbolAtLocation(source);
|
|
899
|
+
for (const exported of module ? checker.getExportsOfModule(module) : []) {
|
|
900
|
+
const list = candidates.get(exported.name) ?? [];
|
|
901
|
+
list.push(source);
|
|
902
|
+
candidates.set(exported.name, list);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const isTypePos = (n) => {
|
|
906
|
+
for (let parent = n.parent; parent; parent = parent.parent) {
|
|
907
|
+
if (ts.isImportDeclaration(parent) || ts.isVariableDeclaration(parent) || ts.isFunctionDeclaration(parent))
|
|
908
|
+
return false;
|
|
909
|
+
if (ts.isTypeNode(parent) || ts.isTypeAliasDeclaration(parent) || ts.isInterfaceDeclaration(parent))
|
|
910
|
+
return true;
|
|
911
|
+
if (ts.isStatement(parent))
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
return false;
|
|
915
|
+
};
|
|
916
|
+
// WALKTHROUGH
|
|
917
|
+
for (const src of program.getSourceFiles()) {
|
|
918
|
+
if (!src.fileName.startsWith(`${cfg.root}${sep}`) || !C_M_JS_TS_X_REGEX.test(src.fileName))
|
|
919
|
+
continue;
|
|
920
|
+
const missing = new Map();
|
|
921
|
+
const importedNames = new Set();
|
|
922
|
+
for (const statement of src.statements.filter(ts.isImportDeclaration)) {
|
|
923
|
+
if (!statement.importClause)
|
|
924
|
+
continue;
|
|
925
|
+
if (statement.importClause.name)
|
|
926
|
+
importedNames.add(statement.importClause.name.text);
|
|
927
|
+
if (statement.importClause.namedBindings && ts.isNamespaceImport(statement.importClause.namedBindings))
|
|
928
|
+
importedNames.add(statement.importClause.namedBindings.name.text);
|
|
929
|
+
if (statement.importClause.namedBindings && ts.isNamedImports(statement.importClause.namedBindings))
|
|
930
|
+
for (const element of statement.importClause.namedBindings.elements)
|
|
931
|
+
importedNames.add(element.name.text);
|
|
932
|
+
}
|
|
933
|
+
const visit = (n) => {
|
|
934
|
+
const intrinsicJsxTag = ts.isIdentifier(n) && (ts.isJsxOpeningLikeElement(n.parent) || ts.isJsxClosingElement(n.parent)) && n.parent.tagName === n && /^[a-z]/.test(n.text);
|
|
935
|
+
const propertyName = ts.isIdentifier(n) && (('name' in n.parent && n.parent.name === n) || ('propertyName' in n.parent && n.parent.propertyName === n));
|
|
936
|
+
if (ts.isIdentifier(n) &&
|
|
937
|
+
!intrinsicJsxTag &&
|
|
938
|
+
!propertyName &&
|
|
939
|
+
!checker.getSymbolAtLocation(n) &&
|
|
940
|
+
!ts.isPropertyAccessExpression(n.parent) &&
|
|
941
|
+
!ts.isPropertyAccessChain(n.parent) &&
|
|
942
|
+
!ts.isQualifiedName(n.parent) &&
|
|
943
|
+
!importedNames.has(n.text) &&
|
|
944
|
+
!ts.isImportDeclaration(n.parent) &&
|
|
945
|
+
!ts.isImportSpecifier(n.parent) &&
|
|
946
|
+
!ts.isExportSpecifier(n.parent)) {
|
|
947
|
+
const srcs = candidates.get(n.text) ?? [];
|
|
948
|
+
const unique = [...new Set(srcs)].filter((c) => c !== src);
|
|
949
|
+
if (unique.length === 1)
|
|
950
|
+
missing.set(n.text, { source: unique[0], typeOnly: isTypePos(n) });
|
|
951
|
+
}
|
|
952
|
+
ts.forEachChild(n, visit);
|
|
953
|
+
};
|
|
954
|
+
visit(src);
|
|
955
|
+
if (!missing.size)
|
|
956
|
+
continue;
|
|
957
|
+
const imports = [...missing.entries()].reduce((g, [n, v]) => {
|
|
958
|
+
const specifier = relative(dirname(src.fileName), v.source.fileName)
|
|
959
|
+
.replace(/\\/g, '/')
|
|
960
|
+
.replace(/\.(?:c|m)?tsx?$/, '')
|
|
961
|
+
.replace(/\.jsx?$/, '');
|
|
962
|
+
const moduleName = specifier.startsWith('.') ? specifier : `./${specifier}`;
|
|
963
|
+
const key = `${v.typeOnly ? 'type:' : 'value:'}${moduleName}`;
|
|
964
|
+
const names = g.get(key) ?? { moduleName, typeOnly: v.typeOnly, names: [] };
|
|
965
|
+
names.names.push(n);
|
|
966
|
+
g.set(key, names);
|
|
967
|
+
return g;
|
|
968
|
+
}, new Map());
|
|
969
|
+
const text = readFileSync(src.fileName, 'utf8');
|
|
970
|
+
const importText = [...imports.values()].map((g) => `import ${g.typeOnly ? 'type ' : ''}{ ${g.names.sort().join(', ')} } from '${g.moduleName}';`).join('\n');
|
|
971
|
+
const firstBody = src.statements.find((s) => !ts.isImportDeclaration(s));
|
|
972
|
+
const pos = firstBody?.getStart(src) ?? 0;
|
|
973
|
+
const updated = `${text.slice(0, pos)}${importText}\n${text.slice(pos)}`;
|
|
974
|
+
if (updated !== text)
|
|
975
|
+
writeFileSync(src.fileName, updated);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Runs Knip once and validates its complete machine-readable issue inventory.
|
|
980
|
+
*
|
|
981
|
+
* @param cfg Quality configuration defining the repository and Knip executable.
|
|
982
|
+
*
|
|
983
|
+
* @return Validated issue records reported by Knip.
|
|
984
|
+
*/
|
|
985
|
+
function readKnipIssues(cfg) {
|
|
986
|
+
const knip = toolPath(cfg, cfg.tools?.knip, 'knip');
|
|
987
|
+
const result = spawnSync(knip, ['--reporter', 'json', '--no-config-hints'], { cwd: cfg.root, encoding: 'utf8', env: process.env, maxBuffer: TOOL_OUTPUT_MAX_BUFFER });
|
|
988
|
+
if (result.error || (result.status !== 0 && result.status !== 1))
|
|
989
|
+
throw new Error(`Knip failed: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
990
|
+
let issues;
|
|
991
|
+
try {
|
|
992
|
+
const parsed = JSON.parse(result.stdout);
|
|
993
|
+
if (!Array.isArray(parsed.issues))
|
|
994
|
+
throw new Error('missing issues array');
|
|
995
|
+
issues = parsed.issues;
|
|
996
|
+
for (const issue of issues) {
|
|
997
|
+
if (!issue || typeof issue.file !== 'string' || !issue.file)
|
|
998
|
+
throw new Error('finding without a source file');
|
|
999
|
+
assertKnipPath(cfg, issue.file, 'finding source');
|
|
1000
|
+
for (const [kind, val] of Object.entries(issue)) {
|
|
1001
|
+
if (kind === 'file')
|
|
1002
|
+
continue;
|
|
1003
|
+
if (!Array.isArray(val))
|
|
1004
|
+
throw new Error(`invalid ${kind} findings for ${issue.file}`);
|
|
1005
|
+
for (const item of val) {
|
|
1006
|
+
if (!item || typeof item.name !== 'string' || !item.name || (item.line !== undefined && (!Number.isInteger(item.line) || item.line < 1)))
|
|
1007
|
+
throw new Error(`malformed ${kind} finding for ${issue.file}`);
|
|
1008
|
+
if (kind === 'files')
|
|
1009
|
+
assertKnipPath(cfg, item.name, 'unreachable file');
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
catch (err) {
|
|
1015
|
+
throw new Error(`Knip returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1016
|
+
}
|
|
1017
|
+
if (result.status === 1 && issues.length === 0)
|
|
1018
|
+
throw new Error('Knip reported failure without findings');
|
|
1019
|
+
return issues;
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Rejects malformed, missing, or out-of-repository paths in Knip's inventory.
|
|
1023
|
+
*
|
|
1024
|
+
* @param cfg Quality configuration defining the repository boundary.
|
|
1025
|
+
* @param path Relative Knip path to validate.
|
|
1026
|
+
* @param label Description used in validation errors.
|
|
1027
|
+
*
|
|
1028
|
+
* @return The resolved absolute path inside the repository.
|
|
1029
|
+
*/
|
|
1030
|
+
function assertKnipPath(cfg, path, label) {
|
|
1031
|
+
const absolute = resolve(cfg.root, path);
|
|
1032
|
+
const rel = relative(cfg.root, absolute);
|
|
1033
|
+
if (isAbsolute(path) || rel === '..' || rel.startsWith(`..${sep}`))
|
|
1034
|
+
throw new Error(`Knip returned a ${label} path outside the repository: ${path}`);
|
|
1035
|
+
if (!existsSync(absolute))
|
|
1036
|
+
throw new Error(`Knip returned a ${label} path that does not exist: ${path}`);
|
|
1037
|
+
const realRoot = realpathSync(cfg.root);
|
|
1038
|
+
const realPath = realpathSync(absolute);
|
|
1039
|
+
const realRelative = relative(realRoot, realPath);
|
|
1040
|
+
if (realRelative === '..' || realRelative.startsWith(`..${sep}`) || isAbsolute(realRelative))
|
|
1041
|
+
throw new Error(`Knip returned a ${label} symlink outside the repository: ${path}`);
|
|
1042
|
+
return absolute;
|
|
1043
|
+
}
|
|
1044
|
+
/** Converts Knip's validated issue inventory into dead-code review findings.
|
|
1045
|
+
* @param cfg Quality configuration defining scope and explicit entry-point contracts.
|
|
1046
|
+
* @param importedConsumers Export names referenced by configured source files.
|
|
1047
|
+
* @returns Knip findings with safe export-modifier changes marked autofixable.
|
|
1048
|
+
*/
|
|
1049
|
+
function collectKnipFindings(cfg, importedConsumers = importedExportConsumers(cfg)) {
|
|
1050
|
+
const issues = readKnipIssues(cfg);
|
|
1051
|
+
const findings = [];
|
|
1052
|
+
for (const issue of issues) {
|
|
1053
|
+
const filePath = assertKnipPath(cfg, issue.file, 'finding source');
|
|
1054
|
+
for (const [kind, val] of Object.entries(issue)) {
|
|
1055
|
+
if (kind === 'file')
|
|
1056
|
+
continue;
|
|
1057
|
+
for (const item of val) {
|
|
1058
|
+
if (kind === 'exports' && isFrameworkEntryExport(cfg, issue.file, item.name))
|
|
1059
|
+
continue;
|
|
1060
|
+
const location = kind === 'files' ? item.name : issue.file;
|
|
1061
|
+
const autoFixable = (kind === 'exports' || kind === 'types') && !importedConsumers.get(filePath)?.has(item.name) && knipExportCanBeMadePrivate(filePath, item.name);
|
|
1062
|
+
const reason = autoFixable
|
|
1063
|
+
? 'Knip identifies an unconsumed named export with a direct, unambiguous declaration.'
|
|
1064
|
+
: kind === 'files'
|
|
1065
|
+
? 'Knip considers this file unreachable; file deletion can affect dynamic, generated, or framework references.'
|
|
1066
|
+
: importedConsumers.get(filePath)?.has(item.name)
|
|
1067
|
+
? 'Knip reports this export as unused, but a configured source consumer was found.'
|
|
1068
|
+
: `Knip reports an unused ${kind} that the safe export rewrite cannot identify unambiguously.`;
|
|
1069
|
+
const agentAction = autoFixable
|
|
1070
|
+
? 'Run the canonical dead-code autofix; it should remove only this export modifier, then verify the declaration and its local uses remain intact.'
|
|
1071
|
+
: kind === 'files'
|
|
1072
|
+
? 'Inspect imports, dynamic loading, framework discovery, generated inventories, and tests. Delete this file only if those checks prove it unreachable; otherwise correct the explicit entry-point or source-discovery model.'
|
|
1073
|
+
: importedConsumers.get(filePath)?.has(item.name)
|
|
1074
|
+
? 'Reconcile the reported consumer with Knip configuration and module resolution. Preserve the export while it has a real consumer; do not add a broad ignore.'
|
|
1075
|
+
: `Inspect the exact ${kind} and its consumers. Remove it only if it is truly unused; otherwise add the concrete framework/public contract to the explicit entry model.`;
|
|
1076
|
+
findings.push({ rule: `knip/${kind}`, file: location, line: item.line ?? 1, name: item.name, severity: autoFixable ? 'error' : 'warning', autofixable: autoFixable, reason, agentAction });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return findings;
|
|
1081
|
+
}
|
|
1082
|
+
/** Checks whether a name is a configured framework-discovered route export.
|
|
1083
|
+
* @param cfg Quality configuration containing framework entry exports.
|
|
1084
|
+
* @param file Repository-relative source filename reported by Knip.
|
|
1085
|
+
* @param name Exported identifier to check.
|
|
1086
|
+
* @returns Whether the configured file or supported TypeScript route pattern includes the export.
|
|
1087
|
+
*/
|
|
1088
|
+
function isFrameworkEntryExport(cfg, file, name) {
|
|
1089
|
+
const publicRoute = !basename(file).startsWith('_');
|
|
1090
|
+
return Object.entries(cfg.frameworkEntryExports ?? {}).some(([pattern, names]) => {
|
|
1091
|
+
const pathMatches = pattern === file ||
|
|
1092
|
+
(publicRoute && pattern.endsWith('/**/*.ts') && file.startsWith(pattern.slice(0, -8)) && file.endsWith('.ts')) ||
|
|
1093
|
+
(publicRoute && pattern.endsWith('/**/*.tsx') && file.startsWith(pattern.slice(0, -9)) && file.endsWith('.tsx'));
|
|
1094
|
+
return pathMatches && names.includes(name);
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
/** Selects the TypeScript parser mode from a source filename's extension.
|
|
1098
|
+
* @param file Source filename to classify.
|
|
1099
|
+
* @returns The JavaScript, JSX, TypeScript, or TSX compiler script kind.
|
|
1100
|
+
*/
|
|
1101
|
+
function deadCodeScriptKind(file) {
|
|
1102
|
+
if (/\.tsx$/i.test(file))
|
|
1103
|
+
return ts.ScriptKind.TSX;
|
|
1104
|
+
if (/\.jsx$/i.test(file))
|
|
1105
|
+
return ts.ScriptKind.JSX;
|
|
1106
|
+
if (/\.(?:c|m)?js$/i.test(file))
|
|
1107
|
+
return ts.ScriptKind.JS;
|
|
1108
|
+
return ts.ScriptKind.TS;
|
|
1109
|
+
}
|
|
1110
|
+
/** Checks whether Knip identified a direct named export that can become private.
|
|
1111
|
+
* @param file Absolute source filename containing the export.
|
|
1112
|
+
* @param name Exported identifier reported by Knip.
|
|
1113
|
+
* @returns Whether the export modifier can be removed without an ambiguous rewrite.
|
|
1114
|
+
*/
|
|
1115
|
+
function knipExportCanBeMadePrivate(file, name) {
|
|
1116
|
+
if (!existsSync(file) || file.endsWith('.astro'))
|
|
1117
|
+
return false;
|
|
1118
|
+
const srcText = readFileSync(file, 'utf8');
|
|
1119
|
+
const src = ts.createSourceFile(file, srcText, ts.ScriptTarget.Latest, true, deadCodeScriptKind(file));
|
|
1120
|
+
if (src.parseDiagnostics.length)
|
|
1121
|
+
throw new Error(`Cannot safely classify Knip export in unparsable source: ${file}`);
|
|
1122
|
+
for (const statement of src.statements) {
|
|
1123
|
+
if (!ts.canHaveModifiers(statement) || !ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))
|
|
1124
|
+
continue;
|
|
1125
|
+
if (ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword))
|
|
1126
|
+
continue;
|
|
1127
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
1128
|
+
if (statement.exportClause.elements.some((element) => element.name.text === name) && !SHARED_REGEX.test(srcText.slice(statement.exportClause.getStart(src), statement.exportClause.end)))
|
|
1129
|
+
return true;
|
|
1130
|
+
}
|
|
1131
|
+
else if (ts.isVariableStatement(statement)) {
|
|
1132
|
+
const declaration = statement.declarationList.declarations[0];
|
|
1133
|
+
if (statement.declarationList.declarations.length === 1 && declaration && ts.isIdentifier(declaration.name) && declaration.name.text === name)
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
1136
|
+
else if ('name' in statement && statement.name && ts.isIdentifier(statement.name) && statement.name.text === name)
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Removes only an unused export modifier that Knip identifies precisely.
|
|
1143
|
+
*
|
|
1144
|
+
* @param cfg Quality configuration defining source scope and Knip output.
|
|
1145
|
+
* @param changedFiles Set updated with files whose export modifiers are removed.
|
|
1146
|
+
*/
|
|
1147
|
+
function fixKnipExports(cfg, changedFiles) {
|
|
1148
|
+
const issues = readKnipIssues(cfg);
|
|
1149
|
+
const importedConsumers = importedExportConsumers(cfg);
|
|
1150
|
+
for (const issue of issues) {
|
|
1151
|
+
const exports = [...(Array.isArray(issue.exports) ? issue.exports : []), ...(Array.isArray(issue.types) ? issue.types : [])];
|
|
1152
|
+
if (!exports.length || issue.file.endsWith('.astro'))
|
|
1153
|
+
continue;
|
|
1154
|
+
const file = assertKnipPath(cfg, issue.file, 'finding source');
|
|
1155
|
+
const src = readFileSync(file, 'utf8');
|
|
1156
|
+
const ast = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, deadCodeScriptKind(file));
|
|
1157
|
+
if (ast.parseDiagnostics.length)
|
|
1158
|
+
throw new Error(`Cannot safely fix Knip exports in unparsable source: ${issue.file}`);
|
|
1159
|
+
const edits = [];
|
|
1160
|
+
const externalConsumers = importedConsumers.get(resolve(file));
|
|
1161
|
+
const removableNames = new Set(exports
|
|
1162
|
+
.filter((finding) => finding && typeof finding.name === 'string' && !isFrameworkEntryExport(cfg, issue.file, finding.name) && !externalConsumers?.has(finding.name))
|
|
1163
|
+
.map((finding) => finding.name));
|
|
1164
|
+
const handledNames = new Set();
|
|
1165
|
+
for (const statement of ast.statements) {
|
|
1166
|
+
if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause))
|
|
1167
|
+
continue;
|
|
1168
|
+
const removed = statement.exportClause.elements.filter((specifier) => removableNames.has(specifier.name.text));
|
|
1169
|
+
if (!removed.length || SHARED_REGEX.test(src.slice(statement.exportClause.getStart(ast), statement.exportClause.end)))
|
|
1170
|
+
continue;
|
|
1171
|
+
for (const specifier of removed)
|
|
1172
|
+
handledNames.add(specifier.name.text);
|
|
1173
|
+
const retained = statement.exportClause.elements.filter((specifier) => !removableNames.has(specifier.name.text));
|
|
1174
|
+
let end = statement.end;
|
|
1175
|
+
if (!retained.length) {
|
|
1176
|
+
if (src[end] === '\r' && src[end + 1] === '\n')
|
|
1177
|
+
end += 2;
|
|
1178
|
+
else if (src[end] === '\n')
|
|
1179
|
+
end++;
|
|
1180
|
+
const replacement = statement.moduleSpecifier && !statement.isTypeOnly ? `import ${statement.moduleSpecifier.getText(ast)}${src.slice(statement.moduleSpecifier.end, statement.end)}` : '';
|
|
1181
|
+
edits.push({ start: statement.getStart(ast), end, text: replacement });
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
const keptClause = `{ ${retained.map((specifier) => specifier.getText(ast)).join(', ')} }`;
|
|
1185
|
+
edits.push({
|
|
1186
|
+
start: statement.getStart(ast),
|
|
1187
|
+
end: statement.end,
|
|
1188
|
+
text: `${src.slice(statement.getStart(ast), statement.exportClause.getStart(ast))}${keptClause}${src.slice(statement.exportClause.end, statement.end)}`,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
for (const finding of exports) {
|
|
1193
|
+
if (!finding || typeof finding.name !== 'string' || !finding.name)
|
|
1194
|
+
throw new Error(`Knip returned malformed export findings for ${issue.file}`);
|
|
1195
|
+
if (!removableNames.has(finding.name) || isFrameworkEntryExport(cfg, issue.file, finding.name) || handledNames.has(finding.name))
|
|
1196
|
+
continue;
|
|
1197
|
+
const declaration = ast.statements.find((statement) => {
|
|
1198
|
+
if (!ts.canHaveModifiers(statement) || !ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))
|
|
1199
|
+
return false;
|
|
1200
|
+
if (ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword))
|
|
1201
|
+
return false;
|
|
1202
|
+
if (ts.isVariableStatement(statement))
|
|
1203
|
+
return (statement.declarationList.declarations.length === 1 &&
|
|
1204
|
+
ts.isIdentifier(statement.declarationList.declarations[0].name) &&
|
|
1205
|
+
statement.declarationList.declarations[0].name.text === finding.name);
|
|
1206
|
+
return 'name' in statement && statement.name && ts.isIdentifier(statement.name) && statement.name.text === finding.name;
|
|
1207
|
+
});
|
|
1208
|
+
if (!declaration || (finding.line && declaration.getStart(ast) > ast.getPositionOfLineAndCharacter(finding.line - 1, 0)))
|
|
1209
|
+
continue;
|
|
1210
|
+
const modifier = ts.getModifiers(declaration)?.find((i) => i.kind === ts.SyntaxKind.ExportKeyword);
|
|
1211
|
+
if (modifier) {
|
|
1212
|
+
const end = src[modifier.end] === ' ' || src[modifier.end] === '\t' ? modifier.end + 1 : modifier.end;
|
|
1213
|
+
edits.push({ start: modifier.getStart(ast), end });
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const uniqueEdits = [...new Map(edits.map((edit) => [edit.start, edit])).values()].sort((left, right) => right.start - left.start);
|
|
1217
|
+
if (uniqueEdits.length) {
|
|
1218
|
+
const output = uniqueEdits.reduce((text, edit) => `${text.slice(0, edit.start)}${edit.text ?? ''}${text.slice(edit.end)}`, src);
|
|
1219
|
+
writeFileSync(file, output);
|
|
1220
|
+
changedFiles.add(file);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/** Finds export names that configured source files import and actually reference. */
|
|
1225
|
+
const importedExportConsumerCache = new WeakMap();
|
|
1226
|
+
/** Builds a cache of exported names referenced by configured source consumers.
|
|
1227
|
+
* @param cfg Quality configuration defining roots, aliases, and skipped paths.
|
|
1228
|
+
* @returns Export names grouped by their resolved absolute source file.
|
|
1229
|
+
*/
|
|
1230
|
+
function importedExportConsumers(cfg) {
|
|
1231
|
+
const srcFiles = filesFor(cfg, true).filter((path) => C_M_JS_TS_X_CLASS_2.test(path) || path.endsWith('.astro'));
|
|
1232
|
+
const signature = srcFiles
|
|
1233
|
+
.map((f) => {
|
|
1234
|
+
const stats = statSync(f);
|
|
1235
|
+
return `${f}:${stats.mtimeMs}:${stats.size}`;
|
|
1236
|
+
})
|
|
1237
|
+
.join('\n');
|
|
1238
|
+
const cached = importedExportConsumerCache.get(cfg);
|
|
1239
|
+
if (cached?.signature === signature)
|
|
1240
|
+
return cached.consumers;
|
|
1241
|
+
const consumers = new Map();
|
|
1242
|
+
const paths = Object.fromEntries(Object.entries(cfg.aliases ?? {}).map(([pattern, target]) => [pattern.endsWith('*') ? pattern : `${pattern}*`, [target.endsWith('*') ? target : `${target}*`]]));
|
|
1243
|
+
const compilerOpts = { baseUrl: cfg.root, moduleResolution: ts.ModuleResolutionKind.Bundler, paths };
|
|
1244
|
+
const moduleResolutionCache = ts.createModuleResolutionCache(cfg.root, (f) => f, compilerOpts);
|
|
1245
|
+
const progressStep = Math.max(1, Math.ceil(srcFiles.length / 20));
|
|
1246
|
+
qualityProgress('Knip import consumers', 0, srcFiles.length);
|
|
1247
|
+
for (const [srcIdx, file] of srcFiles.entries()) {
|
|
1248
|
+
if (srcIdx % progressStep === 0 || srcIdx + 1 === srcFiles.length)
|
|
1249
|
+
qualityProgress('Knip import consumers', srcIdx + 1, srcFiles.length);
|
|
1250
|
+
const raw = readFileSync(file, 'utf8');
|
|
1251
|
+
const astro = file.endsWith('.astro') ? /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw) : null;
|
|
1252
|
+
if (file.endsWith('.astro') && !astro)
|
|
1253
|
+
continue;
|
|
1254
|
+
const frontmatter = astro?.[1] ?? raw;
|
|
1255
|
+
const markup = astro ? raw.slice(astro[0].length) : '';
|
|
1256
|
+
const src = ts.createSourceFile(file, frontmatter, ts.ScriptTarget.Latest, true, file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
1257
|
+
const markupUses = (name) => Boolean(markup) && markup.includes(name);
|
|
1258
|
+
const referencedIdentifiers = new Set();
|
|
1259
|
+
const namespaceProperties = new Map();
|
|
1260
|
+
const collectReferences = (node) => {
|
|
1261
|
+
if (ts.isImportDeclaration(node))
|
|
1262
|
+
return;
|
|
1263
|
+
if (ts.isIdentifier(node))
|
|
1264
|
+
referencedIdentifiers.add(node.text);
|
|
1265
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
1266
|
+
const properties = namespaceProperties.get(node.expression.text) ?? new Set();
|
|
1267
|
+
properties.add(node.name.text);
|
|
1268
|
+
namespaceProperties.set(node.expression.text, properties);
|
|
1269
|
+
}
|
|
1270
|
+
ts.forEachChild(node, collectReferences);
|
|
1271
|
+
};
|
|
1272
|
+
collectReferences(src);
|
|
1273
|
+
for (const statement of src.statements) {
|
|
1274
|
+
if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
1275
|
+
continue;
|
|
1276
|
+
const resolved = ts.resolveModuleName(statement.moduleSpecifier.text, file, compilerOpts, ts.sys, moduleResolutionCache).resolvedModule?.resolvedFileName;
|
|
1277
|
+
if (!resolved)
|
|
1278
|
+
continue;
|
|
1279
|
+
const owner = resolve(resolved);
|
|
1280
|
+
const names = consumers.get(owner) ?? new Set();
|
|
1281
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
1282
|
+
for (const specifier of statement.exportClause.elements)
|
|
1283
|
+
names.add(specifier.propertyName?.text ?? specifier.name.text);
|
|
1284
|
+
consumers.set(owner, names);
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (!ts.isImportDeclaration(statement))
|
|
1288
|
+
continue;
|
|
1289
|
+
const bindings = statement.importClause?.namedBindings;
|
|
1290
|
+
if (!bindings)
|
|
1291
|
+
continue;
|
|
1292
|
+
if (ts.isNamedImports(bindings)) {
|
|
1293
|
+
for (const specifier of bindings.elements) {
|
|
1294
|
+
if (referencedIdentifiers.has(specifier.name.text) || markupUses(specifier.name.text))
|
|
1295
|
+
names.add(specifier.propertyName?.text ?? specifier.name.text);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
else if (ts.isNamespaceImport(bindings)) {
|
|
1299
|
+
for (const property of namespaceProperties.get(bindings.name.text) ?? [])
|
|
1300
|
+
names.add(property);
|
|
1301
|
+
if (markupUses(bindings.name.text)) {
|
|
1302
|
+
for (const match of markup.matchAll(new RegExp(`<\\s*${bindings.name.text}\\.([A-Za-z_$][\\w$]*)(?:[\\s/>])`, 'g')))
|
|
1303
|
+
names.add(match[1]);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (names.size)
|
|
1307
|
+
consumers.set(owner, names);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
importedExportConsumerCache.set(cfg, { signature, consumers });
|
|
1311
|
+
return consumers;
|
|
1312
|
+
}
|
|
1313
|
+
/** Accepts proposed edits only when they add no TypeScript errors to changed files.
|
|
1314
|
+
* @param program TypeScript program representing the original source.
|
|
1315
|
+
* @param proposed Proposed source text keyed by absolute filename.
|
|
1316
|
+
* @param original Original source text for the same files.
|
|
1317
|
+
* @param onBatch Optional progress callback for each validation batch.
|
|
1318
|
+
* @returns Accepted source edits and filenames rejected by the type-safety check.
|
|
1319
|
+
*/
|
|
1320
|
+
function acceptTypeSafeEdits(program, proposed, original, onBatch) {
|
|
1321
|
+
const rejected = new Set();
|
|
1322
|
+
let checks = 0;
|
|
1323
|
+
const accept = (entries, base) => {
|
|
1324
|
+
if (!entries.length)
|
|
1325
|
+
return base;
|
|
1326
|
+
onBatch?.(++checks, entries.length);
|
|
1327
|
+
const candidate = new Map(base);
|
|
1328
|
+
for (const [file, text] of entries)
|
|
1329
|
+
candidate.set(file, text);
|
|
1330
|
+
if (!deadCodeEditsIntroduceTypeErrors(program, candidate))
|
|
1331
|
+
return candidate;
|
|
1332
|
+
if (entries.length === 1) {
|
|
1333
|
+
rejected.add(entries[0][0]);
|
|
1334
|
+
return base;
|
|
1335
|
+
}
|
|
1336
|
+
const midpoint = Math.floor(entries.length / 2);
|
|
1337
|
+
const afterLeft = accept(entries.slice(0, midpoint), base);
|
|
1338
|
+
return accept(entries.slice(midpoint), afterLeft);
|
|
1339
|
+
};
|
|
1340
|
+
const baseline = new Map([...proposed.keys()].map((f) => [f, original.get(f)]));
|
|
1341
|
+
return {
|
|
1342
|
+
accepted: accept([...proposed].sort(([left], [right]) => left.localeCompare(right)), baseline),
|
|
1343
|
+
rejected,
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
/** Prints dead-code findings and sets a failing process status for autofixable issues.
|
|
1347
|
+
* @param findings Findings collected by the dead-code coordinator.
|
|
1348
|
+
* @param typeSafetyVerified Whether the candidate autofixes passed TypeScript safety checks.
|
|
1349
|
+
*/
|
|
1350
|
+
function printDeadCodeFindings(findings, typeSafetyVerified = true) {
|
|
1351
|
+
if (reportStages) {
|
|
1352
|
+
const stage = reportStages.get('dead-code');
|
|
1353
|
+
if (stage) {
|
|
1354
|
+
stage.findings = findings.length;
|
|
1355
|
+
if (findings.length)
|
|
1356
|
+
stage.status = 'findings';
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
if (!findings.length) {
|
|
1360
|
+
console.error(' ✓ dead-code checks passed; no autofixable findings or manual warnings remain');
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
const errs = findings.filter((finding) => finding.severity === 'error');
|
|
1364
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning');
|
|
1365
|
+
for (const finding of findings) {
|
|
1366
|
+
console.error(` - [${finding.severity.toUpperCase()}] ${finding.rule}: ${finding.file}:${finding.line} ${finding.name}`);
|
|
1367
|
+
console.error(` Reason: ${finding.reason}`);
|
|
1368
|
+
const autofix = !finding.autofixable ? 'not safe or not supported' : typeSafetyVerified ? 'available' : 'candidate; fix mode checks TypeScript before writing';
|
|
1369
|
+
console.error(` Autofix: ${autofix}`);
|
|
1370
|
+
console.error(` Agent action: ${finding.agentAction}`);
|
|
1371
|
+
}
|
|
1372
|
+
console.error(` ${errs.length} error${errs.length === 1 ? '' : 's'}; ${warnings.length} manual warning${warnings.length === 1 ? '' : 's'}.`);
|
|
1373
|
+
if (errs.length)
|
|
1374
|
+
process.exitCode = 1;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Runs every dead-code detector through one fix-and-verify boundary.
|
|
1378
|
+
*
|
|
1379
|
+
* @param cfg Quality configuration defining source scope, tools, and policy exceptions.
|
|
1380
|
+
* @param fix Whether eligible changes should be written.
|
|
1381
|
+
* @param all Whether to inspect all configured source files.
|
|
1382
|
+
* @param verify Whether to rerun the detectors after applying fixes.
|
|
1383
|
+
* @param emit Whether to print stage headings and findings.
|
|
1384
|
+
*
|
|
1385
|
+
* @return Remaining findings after any eligible fixes and verification.
|
|
1386
|
+
*/
|
|
1387
|
+
function runDeadCode(cfg, fix, all, verify = true, emit = true) {
|
|
1388
|
+
if (emit)
|
|
1389
|
+
qualityHeader('dead-code', `${fix ? 'Fixing safe cases and checking' : 'Read-only check of'} unused files, exports, declarations, and single-use code`);
|
|
1390
|
+
const changedFiles = new Set();
|
|
1391
|
+
const srcSnapshot = fix ? new Map(filesFor(cfg, true).map((f) => [f, readFileSync(f, 'utf8')])) : new Map();
|
|
1392
|
+
const initialInventory = fix ? new Set(srcSnapshot.keys()) : new Set();
|
|
1393
|
+
if (fix && emit)
|
|
1394
|
+
console.error(` Preparing TypeScript baseline for ${srcSnapshot.size} configured source files...`);
|
|
1395
|
+
const typeProgram = fix ? deadCodeTypeProgram(cfg) : null;
|
|
1396
|
+
if (fix && emit)
|
|
1397
|
+
console.error(' TypeScript baseline ready.');
|
|
1398
|
+
const typeRejectedFiles = new Set();
|
|
1399
|
+
const eslintReportsByPath = new Map();
|
|
1400
|
+
const eslintScannedContents = new Map();
|
|
1401
|
+
let importedConsumersForRun;
|
|
1402
|
+
const scanEslint = (selectedFiles) => {
|
|
1403
|
+
const reports = collectEslintReports(cfg, all, selectedFiles);
|
|
1404
|
+
for (const report of reports) {
|
|
1405
|
+
const f = resolve(report.filePath);
|
|
1406
|
+
eslintReportsByPath.set(f, report);
|
|
1407
|
+
eslintScannedContents.set(f, readFileSync(f, 'utf8'));
|
|
1408
|
+
}
|
|
1409
|
+
return reports;
|
|
1410
|
+
};
|
|
1411
|
+
const scanDirtyEslintFiles = () => {
|
|
1412
|
+
const dirtyFiles = [...eslintScannedContents].filter(([f, content]) => existsSync(f) && readFileSync(f, 'utf8') !== content).map(([f]) => f);
|
|
1413
|
+
return dirtyFiles.length ? scanEslint(dirtyFiles) : [];
|
|
1414
|
+
};
|
|
1415
|
+
if (fix) {
|
|
1416
|
+
if (qualityCheckEnabled(cfg, 'knip')) {
|
|
1417
|
+
if (emit)
|
|
1418
|
+
qualityHeader('dead-code/knip-fix', 'Removing only unambiguous unused export modifiers');
|
|
1419
|
+
fixKnipExports(cfg, changedFiles);
|
|
1420
|
+
}
|
|
1421
|
+
importedConsumersForRun = qualityCheckEnabled(cfg, 'knip') || qualityCheckEnabled(cfg, 'lint') ? importedExportConsumers(cfg) : undefined;
|
|
1422
|
+
if (qualityCheckEnabled(cfg, 'lint')) {
|
|
1423
|
+
if (emit)
|
|
1424
|
+
console.error(' Scanning configured files for safe unused-declaration fixes...');
|
|
1425
|
+
let reportsToFix = scanEslint();
|
|
1426
|
+
for (let round = 0; round < 5; round++) {
|
|
1427
|
+
if (!reportsToFix.some((report) => report.messages.some((message) => message.ruleId === 'unused-imports/no-unused-vars')))
|
|
1428
|
+
break;
|
|
1429
|
+
if (emit)
|
|
1430
|
+
console.error(` ESLint unused-declaration pass ${round + 1}/5...`);
|
|
1431
|
+
const passChangedFiles = new Set();
|
|
1432
|
+
if (!fixUnusedDeclarations(cfg, reportsToFix, passChangedFiles, importedConsumersForRun))
|
|
1433
|
+
break;
|
|
1434
|
+
for (const file of passChangedFiles)
|
|
1435
|
+
changedFiles.add(file);
|
|
1436
|
+
reportsToFix = scanDirtyEslintFiles();
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (emit)
|
|
1440
|
+
console.error(' Checking and inlining safe single-use declarations...');
|
|
1441
|
+
const singleUseChangedFiles = new Set();
|
|
1442
|
+
fixSingleUseDeclarations(cfg, singleUseChangedFiles, (pass, filesChanged) => {
|
|
1443
|
+
if (emit)
|
|
1444
|
+
console.error(` Single-use rewrite pass ${pass} starting (${filesChanged} source files changed so far).`);
|
|
1445
|
+
});
|
|
1446
|
+
for (const file of singleUseChangedFiles)
|
|
1447
|
+
changedFiles.add(file);
|
|
1448
|
+
const currInventory = new Set(filesFor(cfg, true));
|
|
1449
|
+
const skippedInventory = new Set(cfg.skippedFiles ?? []);
|
|
1450
|
+
const trackedInitialInventory = [...initialInventory].filter((f) => !skippedInventory.has(f));
|
|
1451
|
+
const trackedCurrInventory = [...currInventory].filter((f) => !skippedInventory.has(f));
|
|
1452
|
+
if (trackedCurrInventory.length !== trackedInitialInventory.length || trackedInitialInventory.some((f) => !currInventory.has(f)))
|
|
1453
|
+
throw new Error('Dead-code autofix changed the configured source inventory; file creation or deletion is not an automatic dead-code fix.');
|
|
1454
|
+
const proposed = new Map();
|
|
1455
|
+
for (const file of changedFiles) {
|
|
1456
|
+
const original = srcSnapshot.get(file);
|
|
1457
|
+
if (original === undefined || !existsSync(file))
|
|
1458
|
+
throw new Error(`Dead-code autofix changed source inventory unexpectedly: ${relative(cfg.root, file)}`);
|
|
1459
|
+
const curr = readFileSync(file, 'utf8');
|
|
1460
|
+
if (curr !== original)
|
|
1461
|
+
proposed.set(file, curr);
|
|
1462
|
+
}
|
|
1463
|
+
if (proposed.size) {
|
|
1464
|
+
if (!typeProgram)
|
|
1465
|
+
throw new Error('Dead-code edit validation requires its baseline TypeScript program.');
|
|
1466
|
+
if (emit)
|
|
1467
|
+
console.error(` Validating ${proposed.size} changed files against TypeScript diagnostics...`);
|
|
1468
|
+
const { accepted, rejected } = acceptTypeSafeEdits(typeProgram, proposed, srcSnapshot, (checked, count) => {
|
|
1469
|
+
if (emit)
|
|
1470
|
+
console.error(` TypeScript safety check ${checked} (${count} candidate files).`);
|
|
1471
|
+
});
|
|
1472
|
+
for (const file of rejected) {
|
|
1473
|
+
const original = srcSnapshot.get(file);
|
|
1474
|
+
if (original !== undefined)
|
|
1475
|
+
writeFileSync(file, original);
|
|
1476
|
+
typeRejectedFiles.add(relative(cfg.root, file));
|
|
1477
|
+
changedFiles.delete(file);
|
|
1478
|
+
}
|
|
1479
|
+
for (const [file, text] of accepted)
|
|
1480
|
+
if (readFileSync(file, 'utf8') !== text)
|
|
1481
|
+
writeFileSync(file, text);
|
|
1482
|
+
}
|
|
1483
|
+
if (qualityCheckEnabled(cfg, 'lint'))
|
|
1484
|
+
scanDirtyEslintFiles();
|
|
1485
|
+
}
|
|
1486
|
+
if (!verify)
|
|
1487
|
+
return [];
|
|
1488
|
+
const findings = [];
|
|
1489
|
+
if (qualityCheckEnabled(cfg, 'knip')) {
|
|
1490
|
+
if (emit)
|
|
1491
|
+
qualityHeader('dead-code/knip', 'Checking unused files, exports, dependencies, and reachability');
|
|
1492
|
+
findings.push(...collectKnipFindings(cfg, importedConsumersForRun));
|
|
1493
|
+
}
|
|
1494
|
+
if (qualityCheckEnabled(cfg, 'lint')) {
|
|
1495
|
+
if (!fix)
|
|
1496
|
+
scanEslint();
|
|
1497
|
+
findings.push(...unusedDeclarationFindings(cfg, [...eslintReportsByPath.values()]));
|
|
1498
|
+
}
|
|
1499
|
+
for (const single of singleUseFindings(cfg))
|
|
1500
|
+
findings.push({
|
|
1501
|
+
rule: 'typescript/single-use',
|
|
1502
|
+
file: single.file,
|
|
1503
|
+
line: single.line,
|
|
1504
|
+
name: single.name,
|
|
1505
|
+
severity: single.autofixable ? 'error' : 'warning',
|
|
1506
|
+
autofixable: single.autofixable,
|
|
1507
|
+
reason: single.reason,
|
|
1508
|
+
agentAction: single.agentAction,
|
|
1509
|
+
});
|
|
1510
|
+
for (const finding of documentationFindings(scopedCfg(cfg, true)).filter((i) => i.kind === 'orphan'))
|
|
1511
|
+
findings.push({
|
|
1512
|
+
rule: 'tsdoc/detached',
|
|
1513
|
+
file: finding.file,
|
|
1514
|
+
line: finding.line,
|
|
1515
|
+
name: finding.name,
|
|
1516
|
+
severity: 'warning',
|
|
1517
|
+
autofixable: false,
|
|
1518
|
+
reason: finding.reason,
|
|
1519
|
+
agentAction: 'Attach this TSDoc to the declaration it describes when that relationship is clear; otherwise remove only this detached block. Preserve meaningful constraints and do not move it across unrelated code.',
|
|
1520
|
+
});
|
|
1521
|
+
const finalFindings = findings.map((finding) => typeRejectedFiles.has(finding.file) && finding.autofixable
|
|
1522
|
+
? {
|
|
1523
|
+
...finding,
|
|
1524
|
+
severity: 'warning',
|
|
1525
|
+
autofixable: false,
|
|
1526
|
+
reason: `The proposed rewrite in this file introduced TypeScript diagnostics and was rolled back: ${finding.reason}`,
|
|
1527
|
+
agentAction: 'Inspect the exact declaration and proposed rewrite, preserve semantics and types, then apply a source-level fix or leave this case manual.',
|
|
1528
|
+
}
|
|
1529
|
+
: finding);
|
|
1530
|
+
if (emit)
|
|
1531
|
+
printDeadCodeFindings(finalFindings, fix);
|
|
1532
|
+
return finalFindings;
|
|
1533
|
+
}
|
|
1534
|
+
/** Runs dead-code detectors once without modifying the source tree.
|
|
1535
|
+
* @param cfg Quality configuration defining source scope and tools.
|
|
1536
|
+
* @param all Whether to inspect all configured source files.
|
|
1537
|
+
*/
|
|
1538
|
+
function runDeadCodeReadOnly(cfg, all) {
|
|
1539
|
+
runDeadCode(cfg, false, all);
|
|
1540
|
+
}
|
|
1541
|
+
/** Dispatches the dead-code coordinator in fix or read-only mode.
|
|
1542
|
+
* @param cfg Quality configuration defining source scope and tools.
|
|
1543
|
+
* @param fix Whether eligible changes may be applied to source files.
|
|
1544
|
+
* @param all Whether to inspect all configured source files.
|
|
1545
|
+
*/
|
|
1546
|
+
function runDeadCodeCmd(cfg, fix, all) {
|
|
1547
|
+
if (fix)
|
|
1548
|
+
runDeadCode(cfg, true, all);
|
|
1549
|
+
else
|
|
1550
|
+
runDeadCodeReadOnly(cfg, all);
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Runs Git's whitespace/error diff check for the current repository.
|
|
1554
|
+
*
|
|
1555
|
+
* @param cfg Quality configuration defining the repository root.
|
|
1556
|
+
*
|
|
1557
|
+
* @return Nothing; diff-check failures are reflected in the process exit code.
|
|
1558
|
+
*/
|
|
1559
|
+
function runDiffCheck(cfg) {
|
|
1560
|
+
qualityHeader('diff', 'Checking Git whitespace and patch errors');
|
|
1561
|
+
const status = run(cfg, 'git', ['-c', 'core.pager=cat', 'diff', '--check']);
|
|
1562
|
+
if (status)
|
|
1563
|
+
process.exitCode = status;
|
|
1564
|
+
console.error(` ${status ? '✗ failed' : '✓ passed'}`);
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Prints a grouped set of findings with one normalized line per issue.
|
|
1568
|
+
*
|
|
1569
|
+
* @param findings Findings produced by one quality stage.
|
|
1570
|
+
* @param fmt Converts one finding into a human-readable message.
|
|
1571
|
+
*
|
|
1572
|
+
* @return Nothing; findings and a clean summary are written to the console.
|
|
1573
|
+
*/
|
|
1574
|
+
function printFindings(findings, fmt) {
|
|
1575
|
+
if (reportStages && reportStageName && findings.length) {
|
|
1576
|
+
const stage = reportStages.get(reportStageName);
|
|
1577
|
+
if (stage) {
|
|
1578
|
+
stage.findings += findings.length;
|
|
1579
|
+
stage.status = 'findings';
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
if (!findings.length) {
|
|
1583
|
+
console.error(' ✓ clean');
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
console.warn(` ${findings.length} finding${findings.length === 1 ? '' : 's'}:`);
|
|
1587
|
+
for (const finding of findings)
|
|
1588
|
+
console.warn(` - ${compactOutput(fmt(finding)).join(' ')}`);
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Run the optional, read-only GitHub analytics stage.
|
|
1592
|
+
* @param cfg Quality configuration containing repositories and expected settings.
|
|
1593
|
+
* @param repos Repository overrides supplied to `terra-quality github`.
|
|
1594
|
+
* @param explicit Whether GitHub reporting was requested as the command itself.
|
|
1595
|
+
* @param warn Whether findings should be reported without setting a failing exit code.
|
|
1596
|
+
*/
|
|
1597
|
+
function runGitHubCheck(cfg, repos = [], explicit = false, warn = false) {
|
|
1598
|
+
qualityHeader('github', 'Reading open issues, Dependabot, code scanning, and secret scanning alerts');
|
|
1599
|
+
const targets = repos.length ? repos : (cfg.github?.repositories ?? [inferGitHubRepo(cfg.root)].filter((repo) => Boolean(repo)));
|
|
1600
|
+
if (!targets.length) {
|
|
1601
|
+
const detail = 'Set github.repositories in quality.json or pass --repository owner/name.';
|
|
1602
|
+
reportGitHubData = { schemaVersion: 1, generatedAt: new Date().toISOString(), state: 'partial', detail, repositories: [] };
|
|
1603
|
+
updateGitHubStage('failed', 0);
|
|
1604
|
+
console.error(` GitHub report unavailable: ${detail}`);
|
|
1605
|
+
process.exitCode = 1;
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
if (!hasGitHubAuth()) {
|
|
1609
|
+
const detail = 'GitHub CLI is unavailable or unauthenticated; run gh auth login or set GH_TOKEN.';
|
|
1610
|
+
reportGitHubData = { schemaVersion: 1, generatedAt: new Date().toISOString(), state: 'skipped', detail, repositories: [] };
|
|
1611
|
+
updateGitHubStage(explicit ? 'failed' : 'skipped', 0);
|
|
1612
|
+
console.error(` GitHub report ${explicit ? 'failed' : 'skipped'}: ${detail}`);
|
|
1613
|
+
if (explicit)
|
|
1614
|
+
process.exitCode = 1;
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
try {
|
|
1618
|
+
reportGitHubData = buildGitHubReport(targets, cfg.github?.requiredFeatures, undefined, cfg.github?.featureExemptions);
|
|
1619
|
+
const findings = reportGitHubData.repositories.flatMap((repo) => repo.findings);
|
|
1620
|
+
const failed = reportGitHubData.state === 'partial';
|
|
1621
|
+
updateGitHubStage(failed ? 'failed' : findings.length ? 'findings' : 'passed', findings.length);
|
|
1622
|
+
for (const line of formatGitHubReport(reportGitHubData))
|
|
1623
|
+
console.warn(` ${line}`);
|
|
1624
|
+
if (!warn && findings.some((finding) => finding.category !== 'issue' && finding.category !== 'setting'))
|
|
1625
|
+
process.exitCode = 1;
|
|
1626
|
+
if (failed && !warn)
|
|
1627
|
+
process.exitCode = 1;
|
|
1628
|
+
console.info(' Use --report-json <path> to save detailed findings and agent instructions.');
|
|
1629
|
+
}
|
|
1630
|
+
catch (err) {
|
|
1631
|
+
const detail = err instanceof Error ? err.message : 'Unknown GitHub report error.';
|
|
1632
|
+
reportGitHubData = { schemaVersion: 1, generatedAt: new Date().toISOString(), state: 'partial', detail, repositories: [] };
|
|
1633
|
+
updateGitHubStage('failed', 0);
|
|
1634
|
+
console.error(` GitHub report failed: ${detail}`);
|
|
1635
|
+
process.exitCode = 1;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
/** Update the JSON summary for the active GitHub report stage.
|
|
1639
|
+
* @param status Result state to record.
|
|
1640
|
+
* @param findings Number of GitHub findings in the report.
|
|
1641
|
+
*/
|
|
1642
|
+
function updateGitHubStage(status, findings) {
|
|
1643
|
+
const stage = reportStages?.get('github');
|
|
1644
|
+
if (stage) {
|
|
1645
|
+
stage.status = status;
|
|
1646
|
+
stage.findings = findings;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
/** Infer the current repository slug from its GitHub origin remote.
|
|
1650
|
+
* @param root Repository root to inspect.
|
|
1651
|
+
* @return The owner/name slug, or undefined for a non-GitHub remote.
|
|
1652
|
+
*/
|
|
1653
|
+
function inferGitHubRepo(root) {
|
|
1654
|
+
const remote = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).stdout.trim();
|
|
1655
|
+
return remote.match(/(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/i)?.[1];
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Runs configured generic repository checks as part of the full quality command.
|
|
1659
|
+
*
|
|
1660
|
+
* @param cfg Quality configuration selecting enabled generic checks and their policies.
|
|
1661
|
+
* @param analysis Optional shared analysis context for regex checks in the same run.
|
|
1662
|
+
*
|
|
1663
|
+
* @return Nothing; blocking generic findings are reflected in the process exit code.
|
|
1664
|
+
*/
|
|
1665
|
+
function runGenericChecks(cfg, analysis) {
|
|
1666
|
+
const checks = [
|
|
1667
|
+
['security', cfg.security ? securityFindings(cfg) : []],
|
|
1668
|
+
['repeated-classes', cfg.repeatedClasses ? repeatedClassFindings(cfg, analysis) : []],
|
|
1669
|
+
['regex-dedup', cfg.regexDedup ? regexFindings(cfg, analysis) : []],
|
|
1670
|
+
['ui-patterns', cfg.uiPatterns ? uiPatternFindings(cfg, analysis) : []],
|
|
1671
|
+
];
|
|
1672
|
+
for (const [name, findings] of checks) {
|
|
1673
|
+
qualityHeader(name, `Checking ${name.replace(G_REGEX(), ' ')}`);
|
|
1674
|
+
printFindings(findings, (f) => `${'file' in f ? `${f.file}: ` : ''}${'message' in f ? f.message : 'value' in f ? `${f.count}x ${f.value} (${f.locations.join(', ')})` : 'source' in f ? `${f.count}x ${f.source} (${f.locations.join(', ')})` : f.pattern}`);
|
|
1675
|
+
if (findings.length && (name === 'security' || name === 'ui-patterns'))
|
|
1676
|
+
process.exitCode = 1;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Runs the configured Tailwind canonical-class check and records its CLI report stage.
|
|
1681
|
+
*
|
|
1682
|
+
* @param cfg Quality configuration containing the Tailwind stylesheet and source roots.
|
|
1683
|
+
* @param fix Whether to write Tailwind's canonical class suggestions.
|
|
1684
|
+
*
|
|
1685
|
+
* @return Promise resolving when the diagnostic or autofix completes.
|
|
1686
|
+
*/
|
|
1687
|
+
function runTailwindCanonicalCheck(cfg, fix) {
|
|
1688
|
+
qualityHeader('tailwind-canonical', `${fix ? 'Fixing and checking' : 'Checking'} canonical Tailwind classes`);
|
|
1689
|
+
return runTailwindCanonical(cfg, fix).then(() => undefined, (error) => {
|
|
1690
|
+
const stage = reportStages?.get('tailwind-canonical');
|
|
1691
|
+
if (stage) {
|
|
1692
|
+
stage.status = 'failed';
|
|
1693
|
+
stage.findings = Number(/failed with (\d+) suggestion/.exec(error instanceof Error ? error.message : '')?.[1] ?? 0);
|
|
1694
|
+
}
|
|
1695
|
+
throw error;
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Renames safe repository-local declarations and repairs matching references.
|
|
1700
|
+
*
|
|
1701
|
+
* @param cfg Quality configuration defining source scope and naming protections.
|
|
1702
|
+
* @param all Whether to apply changes to every configured source file.
|
|
1703
|
+
*
|
|
1704
|
+
* @return Nothing; safe naming changes are written to source files.
|
|
1705
|
+
*/
|
|
1706
|
+
function fixNames(cfg, all) {
|
|
1707
|
+
// TypeScript's parser does not understand Astro frontmatter. Keep Astro in
|
|
1708
|
+
// reporting/import scope, but never apply symbol renames to it.
|
|
1709
|
+
const allowed = new Set(filesFor(cfg, all).filter((f) => !isProtectedPath(f) && !f.endsWith('.astro')));
|
|
1710
|
+
let program = ts.createProgram({
|
|
1711
|
+
rootNames: srcFiles(cfg).filter((f) => C_M_JS_TS_X_CLASS_2.test(f)),
|
|
1712
|
+
options: {
|
|
1713
|
+
allowNonTsExtensions: true,
|
|
1714
|
+
allowJs: true,
|
|
1715
|
+
checkJs: false,
|
|
1716
|
+
jsx: ts.JsxEmit.Preserve,
|
|
1717
|
+
module: ts.ModuleKind.ESNext,
|
|
1718
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
1719
|
+
skipLibCheck: true,
|
|
1720
|
+
target: ts.ScriptTarget.ESNext,
|
|
1721
|
+
},
|
|
1722
|
+
});
|
|
1723
|
+
let checker = program.getTypeChecker();
|
|
1724
|
+
// Import repair writes source files. Rebuild the AST afterward so every
|
|
1725
|
+
// subsequent text span is anchored to the current file contents.
|
|
1726
|
+
repairMissingImports(cfg, program, checker);
|
|
1727
|
+
program = ts.createProgram({
|
|
1728
|
+
rootNames: srcFiles(cfg).filter((f) => C_M_JS_TS_X_CLASS_2.test(f)),
|
|
1729
|
+
options: {
|
|
1730
|
+
allowNonTsExtensions: true,
|
|
1731
|
+
allowJs: true,
|
|
1732
|
+
checkJs: false,
|
|
1733
|
+
jsx: ts.JsxEmit.Preserve,
|
|
1734
|
+
module: ts.ModuleKind.ESNext,
|
|
1735
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
1736
|
+
skipLibCheck: true,
|
|
1737
|
+
target: ts.ScriptTarget.ESNext,
|
|
1738
|
+
},
|
|
1739
|
+
});
|
|
1740
|
+
checker = program.getTypeChecker();
|
|
1741
|
+
const isIntrinsicJsxTag = (n) => {
|
|
1742
|
+
return (ts.isJsxOpeningLikeElement(n.parent) || ts.isJsxClosingElement(n.parent)) && n.parent.tagName === n && n.text.length > 0 && n.text[0] === n.text[0].toLowerCase();
|
|
1743
|
+
};
|
|
1744
|
+
const canonical = (symbol) => (symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol);
|
|
1745
|
+
const identifierSymbol = (n) => canonical(ts.isShorthandPropertyAssignment(n.parent) ? (checker.getShorthandAssignmentValueSymbol(n.parent) ?? checker.getSymbolAtLocation(n)) : checker.getSymbolAtLocation(n));
|
|
1746
|
+
const isPropertyName = (n) => (ts.isPropertyAccessExpression(n.parent) && n.parent.name === n) ||
|
|
1747
|
+
(ts.isQualifiedName(n.parent) && n.parent.right === n) ||
|
|
1748
|
+
((ts.isPropertyAssignment(n.parent) || ts.isMethodDeclaration(n.parent) || ts.isMethodSignature(n.parent)) && n.parent.name === n);
|
|
1749
|
+
const hasInitShadow = (n) => {
|
|
1750
|
+
if (!ts.isVariableDeclaration(n.parent) || !n.parent.initializer)
|
|
1751
|
+
return false;
|
|
1752
|
+
const tgt = checker.getSymbolAtLocation(n);
|
|
1753
|
+
let found = false;
|
|
1754
|
+
const visit = (child) => {
|
|
1755
|
+
if (found || child === n)
|
|
1756
|
+
return;
|
|
1757
|
+
if (ts.isIdentifier(child) && !isPropertyName(child) && child.text === n.text && checker.getSymbolAtLocation(child) === tgt)
|
|
1758
|
+
found = true;
|
|
1759
|
+
ts.forEachChild(child, visit);
|
|
1760
|
+
};
|
|
1761
|
+
visit(n.parent.initializer);
|
|
1762
|
+
return found;
|
|
1763
|
+
};
|
|
1764
|
+
const hasInitReferenceToOtherSymbol = (n) => {
|
|
1765
|
+
if (!ts.isVariableDeclaration(n.parent) || !n.parent.initializer)
|
|
1766
|
+
return false;
|
|
1767
|
+
const tgt = checker.getSymbolAtLocation(n);
|
|
1768
|
+
let found = false;
|
|
1769
|
+
const visit = (child) => {
|
|
1770
|
+
if (found || child === n)
|
|
1771
|
+
return;
|
|
1772
|
+
if (ts.isIdentifier(child) && !isPropertyName(child) && child.text === n.text && checker.getSymbolAtLocation(child) !== tgt)
|
|
1773
|
+
found = true;
|
|
1774
|
+
ts.forEachChild(child, visit);
|
|
1775
|
+
};
|
|
1776
|
+
visit(n.parent.initializer);
|
|
1777
|
+
return found;
|
|
1778
|
+
};
|
|
1779
|
+
const hasInitRenameCollision = (n, suggestion) => {
|
|
1780
|
+
if (!ts.isVariableDeclaration(n.parent) || !n.parent.initializer)
|
|
1781
|
+
return false;
|
|
1782
|
+
let found = false;
|
|
1783
|
+
const visit = (child) => {
|
|
1784
|
+
if (found)
|
|
1785
|
+
return;
|
|
1786
|
+
if (ts.isIdentifier(child) && !isPropertyName(child)) {
|
|
1787
|
+
const symbol = checker.getSymbolAtLocation(child);
|
|
1788
|
+
for (const declaration of symbol?.declarations ?? []) {
|
|
1789
|
+
const name = 'name' in declaration && ts.isIdentifier(declaration.name) ? declaration.name : undefined;
|
|
1790
|
+
if (name && name !== n && renameDeclaration(name) === suggestion) {
|
|
1791
|
+
found = true;
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
ts.forEachChild(child, visit);
|
|
1797
|
+
};
|
|
1798
|
+
visit(n.parent.initializer);
|
|
1799
|
+
return found;
|
|
1800
|
+
};
|
|
1801
|
+
const hasInitConsumerCollision = (n, suggestion) => {
|
|
1802
|
+
const tgt = checker.getSymbolAtLocation(n);
|
|
1803
|
+
if (!tgt)
|
|
1804
|
+
return false;
|
|
1805
|
+
const src = n.getSourceFile();
|
|
1806
|
+
if (!allowed.has(src.fileName))
|
|
1807
|
+
return false;
|
|
1808
|
+
{
|
|
1809
|
+
let found = false;
|
|
1810
|
+
const visit = (n) => {
|
|
1811
|
+
if (found)
|
|
1812
|
+
return;
|
|
1813
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === suggestion && n.initializer) {
|
|
1814
|
+
const check = (child) => {
|
|
1815
|
+
if (found)
|
|
1816
|
+
return;
|
|
1817
|
+
if (ts.isIdentifier(child) && checker.getSymbolAtLocation(child) === tgt)
|
|
1818
|
+
found = true;
|
|
1819
|
+
ts.forEachChild(child, check);
|
|
1820
|
+
};
|
|
1821
|
+
check(n.initializer);
|
|
1822
|
+
}
|
|
1823
|
+
ts.forEachChild(n, visit);
|
|
1824
|
+
};
|
|
1825
|
+
visit(src);
|
|
1826
|
+
if (found)
|
|
1827
|
+
return true;
|
|
1828
|
+
}
|
|
1829
|
+
return false;
|
|
1830
|
+
};
|
|
1831
|
+
const protectedNames = new Set([...PROTECTED_IDENTIFIERS, ...(cfg.protectedIdentifiers ?? [])]);
|
|
1832
|
+
const exported = new Set();
|
|
1833
|
+
for (const src of program.getSourceFiles()) {
|
|
1834
|
+
const module = checker.getSymbolAtLocation(src);
|
|
1835
|
+
if (module)
|
|
1836
|
+
for (const s of checker.getExportsOfModule(module))
|
|
1837
|
+
exported.add(s.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s);
|
|
1838
|
+
}
|
|
1839
|
+
const replacements = new Map();
|
|
1840
|
+
const renameSafetyByScope = new Map();
|
|
1841
|
+
for (const src of program.getSourceFiles()) {
|
|
1842
|
+
if (!allowed.has(src.fileName))
|
|
1843
|
+
continue;
|
|
1844
|
+
const inspect = (n) => {
|
|
1845
|
+
if (ts.isIdentifier(n) && isRenamableDeclaration(n)) {
|
|
1846
|
+
const scope = declarationScope(n);
|
|
1847
|
+
const safety = renameSafetyByScope.get(scope) ?? { declared: new Set(), proposals: new Map() };
|
|
1848
|
+
safety.declared.add(n.text);
|
|
1849
|
+
const suggestion = renameDeclaration(n);
|
|
1850
|
+
if (!n.text.startsWith('_') &&
|
|
1851
|
+
!protectedNames.has(n.text) &&
|
|
1852
|
+
!hasInitShadow(n) &&
|
|
1853
|
+
!hasInitReferenceToOtherSymbol(n) &&
|
|
1854
|
+
!hasInitRenameCollision(n, suggestion) &&
|
|
1855
|
+
!hasInitConsumerCollision(n, suggestion)) {
|
|
1856
|
+
if (suggestion !== n.text && !isCallbackDeclaration(n))
|
|
1857
|
+
safety.proposals.set(suggestion, (safety.proposals.get(suggestion) ?? 0) + 1);
|
|
1858
|
+
}
|
|
1859
|
+
renameSafetyByScope.set(scope, safety);
|
|
1860
|
+
}
|
|
1861
|
+
ts.forEachChild(n, inspect);
|
|
1862
|
+
};
|
|
1863
|
+
inspect(src);
|
|
1864
|
+
}
|
|
1865
|
+
for (const src of program.getSourceFiles()) {
|
|
1866
|
+
if (!allowed.has(src.fileName))
|
|
1867
|
+
continue;
|
|
1868
|
+
const inspect = (n) => {
|
|
1869
|
+
if (ts.isIdentifier(n) && isRenamableDeclaration(n) && !n.text.startsWith('_') && !protectedNames.has(n.text) && !hasInitShadow(n) && !hasInitReferenceToOtherSymbol(n)) {
|
|
1870
|
+
const suggestion = renameDeclaration(n);
|
|
1871
|
+
// Reject collisions in this declaration scope below, while allowing
|
|
1872
|
+
// the same concise name in an unrelated sibling function scope.
|
|
1873
|
+
if (hasInitRenameCollision(n, suggestion) || hasInitConsumerCollision(n, suggestion))
|
|
1874
|
+
return;
|
|
1875
|
+
const safety = renameSafetyByScope.get(declarationScope(n));
|
|
1876
|
+
if (!isCallbackDeclaration(n) && (safety?.declared.has(suggestion) || (safety?.proposals.get(suggestion) ?? 0) > 1))
|
|
1877
|
+
return;
|
|
1878
|
+
const symbol = suggestion === n.text ? undefined : identifierSymbol(n);
|
|
1879
|
+
if (symbol && (!exported.has(symbol) || cfg.renameExports === true))
|
|
1880
|
+
replacements.set(symbol, suggestion);
|
|
1881
|
+
}
|
|
1882
|
+
ts.forEachChild(n, inspect);
|
|
1883
|
+
};
|
|
1884
|
+
inspect(src);
|
|
1885
|
+
}
|
|
1886
|
+
const edits = new Map();
|
|
1887
|
+
for (const src of program.getSourceFiles()) {
|
|
1888
|
+
if (!allowed.has(src.fileName))
|
|
1889
|
+
continue;
|
|
1890
|
+
const inspect = (n) => {
|
|
1891
|
+
if (ts.isIdentifier(n) && !isIntrinsicJsxTag(n)) {
|
|
1892
|
+
const replacement = replacements.get(identifierSymbol(n));
|
|
1893
|
+
const astroAttrName = src.fileName.endsWith('.astro') && src.text.slice(n.end, n.end + 2) === '={' && src.text.lastIndexOf('---', n.getStart(src)) >= 0;
|
|
1894
|
+
if (replacement && !astroAttrName) {
|
|
1895
|
+
const shorthandBinding = ts.isBindingElement(n.parent) && n.parent.name === n && !n.parent.propertyName && ts.isObjectBindingPattern(n.parent.parent);
|
|
1896
|
+
const fileEdits = edits.get(src.fileName) ?? [];
|
|
1897
|
+
fileEdits.push({
|
|
1898
|
+
// Identifier `pos` can include a preceding delimiter (for example the
|
|
1899
|
+
// opening parenthesis of a call argument). Anchor edits from `end` so
|
|
1900
|
+
// expanding a shorthand binding cannot consume adjacent punctuation.
|
|
1901
|
+
span: { start: n.getStart(src), length: n.text.length },
|
|
1902
|
+
newText: !src.fileName.endsWith('.astro') && (ts.isShorthandPropertyAssignment(n.parent) || shorthandBinding) ? `${n.text}: ${replacement}` : replacement,
|
|
1903
|
+
});
|
|
1904
|
+
if (ts.isParameter(n.parent) && n.parent.name === n) {
|
|
1905
|
+
const docStart = n.parent.parent.getFullStart();
|
|
1906
|
+
const doc = src.text.slice(docStart, n.parent.parent.getStart(src));
|
|
1907
|
+
const pattern = new RegExp(`(@param\\s+(?:\\{[^}]*\\}\\s*)?)${n.text}\\b`, 'g');
|
|
1908
|
+
for (const match of doc.matchAll(pattern))
|
|
1909
|
+
fileEdits.push({ span: { start: docStart + match.index + match[1].length, length: n.text.length }, newText: replacement });
|
|
1910
|
+
}
|
|
1911
|
+
edits.set(src.fileName, fileEdits);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
ts.forEachChild(n, inspect);
|
|
1915
|
+
};
|
|
1916
|
+
inspect(src);
|
|
1917
|
+
}
|
|
1918
|
+
for (const [fileName, fileEdits] of edits) {
|
|
1919
|
+
const src = readFileSync(fileName, 'utf8');
|
|
1920
|
+
const n = fileEdits.sort((a, b) => b.span.start - a.span.start).reduce((t, e) => `${t.slice(0, e.span.start)}${e.newText}${t.slice(e.span.start + e.span.length)}`, src);
|
|
1921
|
+
if (n !== src)
|
|
1922
|
+
writeFileSync(fileName, n);
|
|
1923
|
+
}
|
|
1924
|
+
// Renames have changed the source text, so discard the previous AST before
|
|
1925
|
+
// inspecting import spans. Otherwise an import edit can land in the wrong
|
|
1926
|
+
// token when a declaration changed length.
|
|
1927
|
+
program = ts.createProgram({
|
|
1928
|
+
rootNames: srcFiles(cfg).filter((f) => C_M_JS_TS_X_CLASS_2.test(f)),
|
|
1929
|
+
options: {
|
|
1930
|
+
allowNonTsExtensions: true,
|
|
1931
|
+
allowJs: true,
|
|
1932
|
+
checkJs: false,
|
|
1933
|
+
jsx: ts.JsxEmit.Preserve,
|
|
1934
|
+
module: ts.ModuleKind.ESNext,
|
|
1935
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
1936
|
+
skipLibCheck: true,
|
|
1937
|
+
target: ts.ScriptTarget.ESNext,
|
|
1938
|
+
},
|
|
1939
|
+
});
|
|
1940
|
+
checker = program.getTypeChecker();
|
|
1941
|
+
// Export renames can leave stale named imports when a consumer was already
|
|
1942
|
+
// out of sync. Repair only an absent import whose concise spelling is an
|
|
1943
|
+
// actual export of the same module; never guess across unrelated symbols.
|
|
1944
|
+
for (const src of program.getSourceFiles()) {
|
|
1945
|
+
if (!allowed.has(src.fileName))
|
|
1946
|
+
continue;
|
|
1947
|
+
const fileEdits = [];
|
|
1948
|
+
const visit = (node) => {
|
|
1949
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
|
|
1950
|
+
const module = checker.getSymbolAtLocation(node.moduleSpecifier);
|
|
1951
|
+
const exportsByName = new Map((module ? checker.getExportsOfModule(module) : []).map((s) => [s.name, s]));
|
|
1952
|
+
for (const specifier of node.importClause.namedBindings.elements) {
|
|
1953
|
+
const oldName = specifier.propertyName?.text ?? specifier.name.text;
|
|
1954
|
+
if (exportsByName.has(oldName))
|
|
1955
|
+
continue;
|
|
1956
|
+
const concise = renameId(oldName);
|
|
1957
|
+
// Never guess a replacement for an unresolved module. A local-looking
|
|
1958
|
+
// specifier is not evidence that the concise spelling is exported.
|
|
1959
|
+
if (concise === oldName || !exportsByName.has(concise))
|
|
1960
|
+
continue;
|
|
1961
|
+
const imported = specifier.propertyName ?? specifier.name;
|
|
1962
|
+
fileEdits.push({ span: { start: imported.getStart(src), length: imported.text.length }, newText: concise });
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
ts.forEachChild(node, visit);
|
|
1966
|
+
};
|
|
1967
|
+
visit(src);
|
|
1968
|
+
if (fileEdits.length) {
|
|
1969
|
+
const srcText = readFileSync(src.fileName, 'utf8');
|
|
1970
|
+
const updated = fileEdits.sort((a, b) => b.span.start - a.span.start).reduce((t, e) => `${t.slice(0, e.span.start)}${e.newText}${t.slice(e.span.start + e.span.length)}`, srcText);
|
|
1971
|
+
if (updated !== srcText)
|
|
1972
|
+
writeFileSync(src.fileName, updated);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Runs the core static quality checks against one source scope.
|
|
1978
|
+
*
|
|
1979
|
+
* @param cfg Quality configuration defining checks, paths, and protections.
|
|
1980
|
+
* @param strict Whether naming errors should contribute to failure status.
|
|
1981
|
+
* @param all Whether to inspect every configured file instead of changed files.
|
|
1982
|
+
* @param analysis Optional analysis context for the selected source scope.
|
|
1983
|
+
*
|
|
1984
|
+
* @return Findings grouped by import, naming, alias, forwarding, and documentation checks.
|
|
1985
|
+
*/
|
|
1986
|
+
export function runQuality(cfg, strict = false, all = true, analysis) {
|
|
1987
|
+
const scoped = scopedCfg(cfg, all);
|
|
1988
|
+
const ctx = analysis ?? createQualityAnalysis(scoped);
|
|
1989
|
+
const result = {
|
|
1990
|
+
imports: qualityCheckEnabled(cfg, 'import') ? checkImports(scoped) : [],
|
|
1991
|
+
naming: qualityCheckEnabled(cfg, 'name') ? namingFindings(scoped, ctx) : [],
|
|
1992
|
+
aliases: qualityCheckEnabled(cfg, 'alias') ? aliasFindings(scoped, ctx) : [],
|
|
1993
|
+
forwarding: qualityCheckEnabled(cfg, 'forward') ? objectForwardingFindings(scoped, ctx) : [],
|
|
1994
|
+
documentation: qualityCheckEnabled(cfg, 'document') ? documentationFindings(scoped, ctx) : [],
|
|
1995
|
+
};
|
|
1996
|
+
const duplicateImports = qualityCheckEnabled(cfg, 'import') ? duplicateImportFindings(ctx.files) : [];
|
|
1997
|
+
if (qualityCheckEnabled(cfg, 'import')) {
|
|
1998
|
+
qualityHeader('imports', 'Checking local import resolution');
|
|
1999
|
+
printFindings(result.imports, (f) => `Unresolved import: ${f.importer} -> ${f.specifier}`);
|
|
2000
|
+
printFindings(duplicateImports, (f) => `Duplicate named import: ${f.file}:${f.line} repeats an import from ${f.module}`);
|
|
2001
|
+
}
|
|
2002
|
+
if (qualityCheckEnabled(cfg, 'name')) {
|
|
2003
|
+
qualityHeader('naming', 'Checking concise, consistent symbol names');
|
|
2004
|
+
printFindings(result.naming, (f) => `${f.severity === 'error' ? 'Naming drift' : 'Naming suggestion'}: ${f.file}: ${f.name} -> ${f.suggestion}`);
|
|
2005
|
+
}
|
|
2006
|
+
if (qualityCheckEnabled(cfg, 'alias')) {
|
|
2007
|
+
qualityHeader('aliases', 'Checking for unnecessary compatibility aliases');
|
|
2008
|
+
printFindings(result.aliases, (f) => `Alias: ${f.file}:${f.line} ${f.kind} ${f.name} -> ${f.replacement}`);
|
|
2009
|
+
}
|
|
2010
|
+
if (qualityCheckEnabled(cfg, 'forward')) {
|
|
2011
|
+
qualityHeader('forwarding', 'Checking object-forwarding opportunities');
|
|
2012
|
+
printFindings(result.forwarding, (f) => `Forwarding suggestion: ${f.file}:${f.line}:${f.column} ${f.callee} repeats ${f.source}.${f.properties.join(', ')}; ${f.recommendation}.`);
|
|
2013
|
+
}
|
|
2014
|
+
if (qualityCheckEnabled(cfg, 'document')) {
|
|
2015
|
+
qualityHeader('documentation', 'Checking meaningful file, function, and parameter documentation');
|
|
2016
|
+
if (result.documentation.length)
|
|
2017
|
+
console.warn(` note: ${DOCUMENTATION_DESCRIPTION_NOTICE}`);
|
|
2018
|
+
printFindings(result.documentation, (f) => `${f.severity === 'warning' ? 'Documentation warning' : 'Documentation requirement'}: ${f.file}:${f.line}:${f.column} ${f.name}; ${f.reason}.`);
|
|
2019
|
+
}
|
|
2020
|
+
if (result.imports.length ||
|
|
2021
|
+
duplicateImports.length ||
|
|
2022
|
+
result.aliases.length ||
|
|
2023
|
+
result.documentation.some((f) => f.severity === 'error') ||
|
|
2024
|
+
(strict && result.naming.some((f) => f.severity === 'error')))
|
|
2025
|
+
process.exitCode = 1;
|
|
2026
|
+
return result;
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Applies documentation maintenance and optional formatting to the requested source scope.
|
|
2030
|
+
*
|
|
2031
|
+
* @param cfg Normalized quality configuration for the current repository.
|
|
2032
|
+
* @param opts CLI options controlling scope and writes.
|
|
2033
|
+
* @param args Command arguments used to select explicit files.
|
|
2034
|
+
* @param quiet Whether the helper should omit the follow-up core quality pass.
|
|
2035
|
+
*
|
|
2036
|
+
* @return Nothing; maintenance and formatter failures are reflected in the process exit code.
|
|
2037
|
+
*/
|
|
2038
|
+
function runDocumentation(cfg, opts, args, quiet) {
|
|
2039
|
+
if (!qualityCheckEnabled(cfg, 'document'))
|
|
2040
|
+
return;
|
|
2041
|
+
qualityHeader('documentation', `${opts.fix ? 'Fixing and checking' : 'Checking'} documentation structure and meaningful descriptions`);
|
|
2042
|
+
const cmdOpts = { ...opts, fix: qualityAutofixEnabled(cfg, 'document', opts.fix) };
|
|
2043
|
+
const files = filesFor(cfg, opts.all);
|
|
2044
|
+
const reqFiles = args.slice(1).filter((a) => !a.startsWith('-'));
|
|
2045
|
+
const tgts = (reqFiles.length ? reqFiles : files).filter((f) => !f.endsWith('.astro'));
|
|
2046
|
+
if (cmdOpts.fix)
|
|
2047
|
+
for (const file of tgts)
|
|
2048
|
+
if (C_M_JS_TS_REGEX.test(file))
|
|
2049
|
+
collapseInlineIfs(file);
|
|
2050
|
+
if (cmdOpts.fix)
|
|
2051
|
+
for (const file of tgts) {
|
|
2052
|
+
maintainSrcFile(cfg.root, file);
|
|
2053
|
+
documentSrcFile(cfg, file);
|
|
2054
|
+
if (cfg.oneLineImports)
|
|
2055
|
+
protectLongImports(file, cfg.prettierPrintWidth);
|
|
2056
|
+
}
|
|
2057
|
+
if (!quiet)
|
|
2058
|
+
runQuality(cfg, opts.strict, opts.all);
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Dispatches one quality CLI command after parsing its scope and autofix flags.
|
|
2062
|
+
*
|
|
2063
|
+
* @param cfg Normalized quality configuration for the current repository.
|
|
2064
|
+
* @param args CLI arguments to dispatch.
|
|
2065
|
+
*
|
|
2066
|
+
* @return A completion promise for asynchronous checks, or nothing for synchronous commands.
|
|
2067
|
+
*/
|
|
2068
|
+
export function runQualityCli(cfg, args = process.argv.slice(2)) {
|
|
2069
|
+
validCliArgs(args);
|
|
2070
|
+
const reportIdx = args.indexOf('--report-json');
|
|
2071
|
+
const reportPath = reportIdx < 0 ? undefined : args[reportIdx + 1];
|
|
2072
|
+
const cmdArgs = reportIdx < 0 ? args : args.filter((_, i) => i !== reportIdx && i !== reportIdx + 1);
|
|
2073
|
+
if (!reportPath) {
|
|
2074
|
+
return dispatchQualityCli(cfg, cmdArgs);
|
|
2075
|
+
}
|
|
2076
|
+
reportStages = new Map();
|
|
2077
|
+
reportStageName = undefined;
|
|
2078
|
+
reportGitHubData = undefined;
|
|
2079
|
+
const writeReport = () => {
|
|
2080
|
+
const executed = [...(reportStages?.values() ?? [])].filter((s) => {
|
|
2081
|
+
if (s.name.includes('/') || s.name.startsWith('recommendation:'))
|
|
2082
|
+
return false;
|
|
2083
|
+
const names = { imports: 'import', naming: 'name', aliases: 'alias', forwarding: 'forward', documentation: 'document', typecheck: 'type' };
|
|
2084
|
+
const check = names[s.name] ?? (s.name.startsWith('command:') ? 'command' : s.name);
|
|
2085
|
+
if (!qualityCheckEnabled(cfg, check))
|
|
2086
|
+
return false;
|
|
2087
|
+
if (s.name === 'security' && !cfg.security)
|
|
2088
|
+
return false;
|
|
2089
|
+
if (s.name === 'repeated-classes' && !cfg.repeatedClasses)
|
|
2090
|
+
return false;
|
|
2091
|
+
if (s.name === 'tailwind-canonical' && !cfg.tailwindCanonical)
|
|
2092
|
+
return false;
|
|
2093
|
+
if (s.name === 'regex-dedup' && !cfg.regexDedup)
|
|
2094
|
+
return false;
|
|
2095
|
+
if (s.name === 'ui-patterns' && !cfg.uiPatterns)
|
|
2096
|
+
return false;
|
|
2097
|
+
return true;
|
|
2098
|
+
});
|
|
2099
|
+
const standard = [
|
|
2100
|
+
'documentation',
|
|
2101
|
+
'format',
|
|
2102
|
+
'imports',
|
|
2103
|
+
'naming',
|
|
2104
|
+
'forwarding',
|
|
2105
|
+
'lint',
|
|
2106
|
+
'dead-code',
|
|
2107
|
+
'typecheck',
|
|
2108
|
+
'diff',
|
|
2109
|
+
'aliases',
|
|
2110
|
+
'security',
|
|
2111
|
+
'repeated-classes',
|
|
2112
|
+
'tailwind-canonical',
|
|
2113
|
+
'regex-dedup',
|
|
2114
|
+
'ui-patterns',
|
|
2115
|
+
'github',
|
|
2116
|
+
];
|
|
2117
|
+
const report = {
|
|
2118
|
+
schemaVersion: 1,
|
|
2119
|
+
generatedAt: new Date().toISOString(),
|
|
2120
|
+
command: cmdArgs.find((arg) => !arg.startsWith('-')) ?? 'quality',
|
|
2121
|
+
scope: cmdArgs.includes('--all') || cmdArgs.includes('quality:all') ? 'all' : 'changed',
|
|
2122
|
+
checks: [...executed, ...standard.filter((name) => !executed.some((s) => s.name === name)).map((name) => ({ name, status: 'skipped', findings: 0 }))],
|
|
2123
|
+
...(reportGitHubData ? { github: reportGitHubData } : {}),
|
|
2124
|
+
};
|
|
2125
|
+
writeFileSync(resolve(cfg.root, reportPath), `${JSON.stringify(report, null, 2)}\n`);
|
|
2126
|
+
reportStages = undefined;
|
|
2127
|
+
reportStageName = undefined;
|
|
2128
|
+
};
|
|
2129
|
+
try {
|
|
2130
|
+
const result = dispatchQualityCli(cfg, cmdArgs);
|
|
2131
|
+
if (result instanceof Promise)
|
|
2132
|
+
return result.finally(writeReport);
|
|
2133
|
+
}
|
|
2134
|
+
catch (error) {
|
|
2135
|
+
writeReport();
|
|
2136
|
+
throw error;
|
|
2137
|
+
}
|
|
2138
|
+
writeReport();
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Route validated arguments to the requested quality command.
|
|
2142
|
+
*
|
|
2143
|
+
* @param cfg Normalized repository quality configuration.
|
|
2144
|
+
* @param args Validated CLI arguments.
|
|
2145
|
+
*
|
|
2146
|
+
* @return A completion promise for asynchronous checks, or nothing for synchronous commands.
|
|
2147
|
+
*/
|
|
2148
|
+
function dispatchQualityCli(cfg, args) {
|
|
2149
|
+
validCliArgs(args);
|
|
2150
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
2151
|
+
const pkg = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'));
|
|
2152
|
+
console.info(pkg.version);
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
2156
|
+
console.info('Usage: terra-quality <command> [options]');
|
|
2157
|
+
console.info('Commands include quality, history, github, document, format, lint, knip, types, single-use, dead-code, tailwind-canonical, security, and domain.');
|
|
2158
|
+
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>.');
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
const cmd = args.find((a) => !a.startsWith('-')) ?? 'quality';
|
|
2162
|
+
const opts = {
|
|
2163
|
+
fix: !args.includes('--warn'),
|
|
2164
|
+
all: args.includes('--all') || cmd === 'quality:all',
|
|
2165
|
+
strict: args.includes('--strict'),
|
|
2166
|
+
skipCommands: args.includes('--skip-commands'),
|
|
2167
|
+
skipRecommendations: args.includes('--skip-recommendations'),
|
|
2168
|
+
};
|
|
2169
|
+
const fix = (check) => qualityAutofixEnabled(cfg, check, opts.fix);
|
|
2170
|
+
if (cfg.tailwindCanonical && qualityCheckEnabled(cfg, 'tailwind-canonical') && ['quality', 'quality:all', 'quality:baseline', 'imports', 'quality:patterns', 'types:architecture'].includes(cmd)) {
|
|
2171
|
+
return runTailwindCanonicalCheck(cfg, fix('tailwindCanonical')).then(() => {
|
|
2172
|
+
const checks = { ...cfg.enabledChecks, 'tailwind-canonical': false };
|
|
2173
|
+
return dispatchQualityCli({ ...cfg, enabledChecks: checks }, args);
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
if (cmd === 'github') {
|
|
2177
|
+
runGitHubCheck(cfg, optVals(args, '--repository'), true, args.includes('--warn'));
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
if (cmd === 'document' || cmd === 'maintain') {
|
|
2181
|
+
runDocumentation(cfg, opts, args, args.includes('--quiet'));
|
|
2182
|
+
if (qualityCheckEnabled(cfg, 'format'))
|
|
2183
|
+
runFormatter(cfg, { ...opts, fix: fix('format') }, args.slice(1));
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
if (cmd === 'format') {
|
|
2187
|
+
if (!qualityCheckEnabled(cfg, 'format'))
|
|
2188
|
+
return;
|
|
2189
|
+
const files = filesFor(cfg, opts.all);
|
|
2190
|
+
const cmdOpts = { ...opts, fix: fix('format') };
|
|
2191
|
+
const tgts = files.filter((f) => !f.endsWith('.astro'));
|
|
2192
|
+
if (cmdOpts.fix)
|
|
2193
|
+
for (const file of tgts)
|
|
2194
|
+
if (C_M_JS_TS_REGEX.test(file))
|
|
2195
|
+
collapseInlineIfs(file);
|
|
2196
|
+
if (cmdOpts.fix)
|
|
2197
|
+
for (const file of tgts) {
|
|
2198
|
+
maintainSrcFile(cfg.root, file);
|
|
2199
|
+
if (cfg.oneLineImports)
|
|
2200
|
+
protectLongImports(file, cfg.prettierPrintWidth);
|
|
2201
|
+
}
|
|
2202
|
+
if (qualityCheckEnabled(cfg, 'format'))
|
|
2203
|
+
runFormatter(cfg, cmdOpts, args.slice(1));
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
if (cmd === 'naming') {
|
|
2207
|
+
if (qualityCheckEnabled(cfg, 'name') && fix('name'))
|
|
2208
|
+
fixNames(cfg, opts.all);
|
|
2209
|
+
runQuality(cfg, opts.strict, opts.all);
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
if (cmd === 'domain') {
|
|
2213
|
+
const name = args[args.indexOf('domain') + 1];
|
|
2214
|
+
if (!name)
|
|
2215
|
+
throw new Error('Pass a quality domain name.');
|
|
2216
|
+
runCfgdCmd(cfg, name);
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
if (cmd === 'single-use') {
|
|
2220
|
+
runDeadCodeCmd(cfg, qualityAutofixEnabled(cfg, 'dead-code', opts.fix), opts.all);
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (cmd === 'dead-code') {
|
|
2224
|
+
runDeadCodeCmd(cfg, qualityAutofixEnabled(cfg, 'dead-code', opts.fix), opts.all);
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
if (cmd === 'tailwind-canonical') {
|
|
2228
|
+
if (!qualityCheckEnabled(cfg, 'tailwind-canonical') || !cfg.tailwindCanonical)
|
|
2229
|
+
return;
|
|
2230
|
+
return runTailwindCanonicalCheck(cfg, fix('tailwindCanonical'));
|
|
2231
|
+
}
|
|
2232
|
+
if (cmd === 'security' || cmd === 'repeated-classes' || cmd === 'regex-dedup' || cmd === 'ui-patterns') {
|
|
2233
|
+
if (cmd === 'repeated-classes' && fix('repeatedClasses')) {
|
|
2234
|
+
const result = fixRepeatedClasses(cfg);
|
|
2235
|
+
console.info(` fixed ${result.fixed} class attributes${result.skipped ? `; ${result.skipped} left advisory` : ''}`);
|
|
2236
|
+
}
|
|
2237
|
+
if (cmd === 'regex-dedup' && fix('regexDedup')) {
|
|
2238
|
+
const result = fixRegexDedup(cfg);
|
|
2239
|
+
console.info(` fixed ${result.fixed} regex literals${result.skipped ? `; ${result.skipped} left advisory` : ''}`);
|
|
2240
|
+
}
|
|
2241
|
+
if (cmd === 'security' || cmd === 'ui-patterns') {
|
|
2242
|
+
const findings = cmd === 'security' ? securityFindings(cfg) : uiPatternFindings(cfg);
|
|
2243
|
+
qualityHeader(cmd, `Checking ${cmd.replace(G_REGEX(), ' ')}`);
|
|
2244
|
+
printFindings(findings, (f) => `${'file' in f ? `${f.file}: ` : ''}${'message' in f ? f.message : 'value' in f ? `${f.count}x ${f.value} (${f.locations.join(', ')})` : 'source' in f ? `${f.count}x ${f.source} (${f.locations.join(', ')})` : f.pattern}`);
|
|
2245
|
+
if (findings.length)
|
|
2246
|
+
process.exitCode = 1;
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
const findings = cmd === 'repeated-classes' ? repeatedClassFindings(cfg) : regexFindings(cfg);
|
|
2250
|
+
qualityHeader(cmd, `Checking ${cmd.replace(G_REGEX(), ' ')}`);
|
|
2251
|
+
printFindings(findings, (f) => `${'file' in f ? `${f.file}: ` : ''}${'message' in f ? f.message : 'value' in f ? `${f.count}x ${f.value} (${f.locations.join(', ')})` : 'source' in f ? `${f.count}x ${f.source} (${f.locations.join(', ')})` : f.pattern}`);
|
|
2252
|
+
if (findings.length)
|
|
2253
|
+
process.exitCode = 1;
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
if (cfg.qualityCommands?.some((c) => c.name === cmd)) {
|
|
2257
|
+
runCfgdCmd(cfg, cmd);
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
if (cmd === 'lint')
|
|
2261
|
+
return qualityCheckEnabled(cfg, 'lint') ? runLint(cfg, { ...opts, fix: fix('lint') }) : undefined;
|
|
2262
|
+
if (cmd === 'knip') {
|
|
2263
|
+
runDeadCodeCmd(cfg, qualityAutofixEnabled(cfg, 'dead-code', opts.fix), opts.all);
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
if (cmd === 'types') {
|
|
2267
|
+
if (!qualityCheckEnabled(cfg, 'type'))
|
|
2268
|
+
return;
|
|
2269
|
+
runTypecheck(cfg, opts.all ? [] : filesFor(cfg, false));
|
|
2270
|
+
if (cfg.typecheckExtensions?.length && !cfg.typecheckRoots?.length)
|
|
2271
|
+
runProjectTypecheck(cfg);
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if (cmd === 'imports' || cmd === 'quality:patterns' || cmd === 'types:architecture') {
|
|
2275
|
+
runQuality(cfg, opts.strict, opts.all);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
if (cmd === 'quality' || cmd === 'quality:all' || cmd === 'quality:baseline') {
|
|
2279
|
+
if (opts.fix && qualityCheckEnabled(cfg, 'alias'))
|
|
2280
|
+
aliasFindings(cfg, true);
|
|
2281
|
+
if (fix('name') && qualityCheckEnabled(cfg, 'name')) {
|
|
2282
|
+
const namingScope = scopedCfg(cfg, opts.all);
|
|
2283
|
+
if (namingFindings(namingScope).length)
|
|
2284
|
+
fixNames(cfg, opts.all);
|
|
2285
|
+
}
|
|
2286
|
+
const scoped = scopedCfg(cfg, opts.all);
|
|
2287
|
+
if (fix('forward') && qualityCheckEnabled(cfg, 'forward')) {
|
|
2288
|
+
const result = fixObjectForwarding(scoped);
|
|
2289
|
+
if (result.fixed || result.skipped)
|
|
2290
|
+
console.info(`Forwarding fixer: ${result.fixed} exact duplicates removed${result.skipped ? `; ${result.skipped} advisory cases retained` : ''}.`);
|
|
2291
|
+
}
|
|
2292
|
+
if (fix('repeatedClasses') && qualityCheckEnabled(cfg, 'repeated-classes')) {
|
|
2293
|
+
const result = fixRepeatedClasses(scoped);
|
|
2294
|
+
if (result.fixed || result.skipped)
|
|
2295
|
+
console.info(`Class fixer: ${result.fixed} attributes replaced${result.skipped ? `; ${result.skipped} advisory cases retained` : ''}.`);
|
|
2296
|
+
}
|
|
2297
|
+
if (fix('regexDedup') && qualityCheckEnabled(cfg, 'regex-dedup')) {
|
|
2298
|
+
const result = fixRegexDedup(scoped);
|
|
2299
|
+
if (result.fixed || result.skipped)
|
|
2300
|
+
console.info(`Regex fixer: ${result.fixed} literals replaced${result.skipped ? `; ${result.skipped} advisory cases retained` : ''}.`);
|
|
2301
|
+
}
|
|
2302
|
+
if (fix('lint') && qualityCheckEnabled(cfg, 'lint'))
|
|
2303
|
+
runLint(cfg, { ...opts, fix: true }, true, true);
|
|
2304
|
+
if (qualityCheckEnabled(cfg, 'document')) {
|
|
2305
|
+
const documentationIssues = documentationFindings(scopedCfg(cfg, opts.all)).filter((f) => f.severity === 'error');
|
|
2306
|
+
if (documentationIssues.length)
|
|
2307
|
+
runDocumentation(cfg, opts, ['document', ...new Set(documentationIssues.map((f) => resolve(cfg.root, f.file))), '--quiet'], true);
|
|
2308
|
+
}
|
|
2309
|
+
if (fix('format') && qualityCheckEnabled(cfg, 'format'))
|
|
2310
|
+
runFormatter(cfg, { ...opts, fix: true }, opts.all ? [] : filesFor(cfg, false));
|
|
2311
|
+
if (opts.fix && qualityCheckEnabled(cfg, 'alias'))
|
|
2312
|
+
aliasFindings(scoped, true);
|
|
2313
|
+
if (qualityCheckEnabled(cfg, 'dead-code'))
|
|
2314
|
+
runDeadCodeCmd(cfg, qualityAutofixEnabled(cfg, 'dead-code', opts.fix), opts.all);
|
|
2315
|
+
if (qualityCheckEnabled(cfg, 'lint'))
|
|
2316
|
+
runLint(cfg, { ...opts, fix: false }, true, true);
|
|
2317
|
+
const analysis = createQualityAnalysis(scoped);
|
|
2318
|
+
runGenericChecks(scoped, analysis);
|
|
2319
|
+
if (qualityCheckEnabled(cfg, 'type') && cfg.tools?.tsc) {
|
|
2320
|
+
runTypecheck(cfg);
|
|
2321
|
+
if (cfg.typecheckExtensions?.length && !cfg.typecheckRoots?.length)
|
|
2322
|
+
runProjectTypecheck(cfg);
|
|
2323
|
+
}
|
|
2324
|
+
if (!opts.skipCommands)
|
|
2325
|
+
runCfgdCmds(cfg);
|
|
2326
|
+
if (!opts.skipRecommendations)
|
|
2327
|
+
runRecommendationCmds(cfg);
|
|
2328
|
+
if (opts.fix && qualityCheckEnabled(cfg, 'import') && qualityAutofixEnabled(cfg, 'document', opts.fix)) {
|
|
2329
|
+
const repeated = duplicateImportFindings(filesFor(cfg, opts.all));
|
|
2330
|
+
for (const file of new Set(repeated.map((finding) => finding.file)))
|
|
2331
|
+
maintainSrcFile(cfg.root, file);
|
|
2332
|
+
}
|
|
2333
|
+
runQuality(cfg, opts.strict, opts.all, analysis);
|
|
2334
|
+
if (qualityCheckEnabled(cfg, 'diff'))
|
|
2335
|
+
runDiffCheck(cfg);
|
|
2336
|
+
if (opts.fix && qualityCheckEnabled(cfg, 'diff'))
|
|
2337
|
+
runDiffCheck(cfg);
|
|
2338
|
+
if (qualityCheckEnabled(cfg, 'github') && cfg.github?.repositories?.length)
|
|
2339
|
+
runGitHubCheck(cfg, [], false, args.includes('--warn'));
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Loads the repository configuration and runs the CLI in one boundary call.
|
|
2344
|
+
*
|
|
2345
|
+
* @param file Optional path to the quality configuration file.
|
|
2346
|
+
* @param args CLI arguments passed to the dispatcher.
|
|
2347
|
+
*
|
|
2348
|
+
* @return A completion promise for asynchronous checks, or nothing for synchronous commands.
|
|
2349
|
+
*/
|
|
2350
|
+
export function runQualityFromCfg(file, args = process.argv.slice(2)) {
|
|
2351
|
+
validCliArgs(args);
|
|
2352
|
+
if (args.some((arg) => ['--version', '-v', '--help', '-h'].includes(arg))) {
|
|
2353
|
+
runQualityCli({}, args);
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
const reportIdx = args.indexOf('--report-json');
|
|
2357
|
+
const cmd = args.find((arg, i) => i !== reportIdx + 1 && !arg.startsWith('-')) ?? 'quality';
|
|
2358
|
+
if (cmd === 'history') {
|
|
2359
|
+
const reportPath = reportIdx < 0 ? undefined : args[reportIdx + 1];
|
|
2360
|
+
if (!reportPath)
|
|
2361
|
+
throw new Error('terra-quality history requires --report-json <path>.');
|
|
2362
|
+
const branch = optVal(args, '--branch');
|
|
2363
|
+
const baseSha = optVal(args, '--base-sha');
|
|
2364
|
+
writeHistoryReport(process.cwd(), reportPath, { ...(branch ? { branch } : {}), ...(baseSha ? { baseSha } : {}) });
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const cfg = loadQualityCfg(file);
|
|
2368
|
+
const lintCmds = new Set(['quality', 'quality:all', 'quality:baseline', 'document', 'maintain', 'dead-code', 'knip', 'lint', 'naming', 'single-use']);
|
|
2369
|
+
if (lintCmds.has(cmd) && qualityCheckEnabled(cfg, 'lint')) {
|
|
2370
|
+
const flatCfg = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts', 'eslint.config.mts', 'eslint.config.cts']
|
|
2371
|
+
.map((config) => resolve(cfg.root, config))
|
|
2372
|
+
.find((config) => existsSync(config));
|
|
2373
|
+
if (!flatCfg)
|
|
2374
|
+
throw new Error(`ESLint flat config is required for ${cmd}. Add eslint.config.js, eslint.config.mjs, or eslint.config.ts at ${cfg.root} before running Terra Quality.`);
|
|
2375
|
+
}
|
|
2376
|
+
return runQualityCli(cfg, args);
|
|
2377
|
+
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Reject unknown commands and flags before loading configuration or invoking a fixer.
|
|
2380
|
+
*
|
|
2381
|
+
* @param args CLI arguments to validate before configuration or source changes.
|
|
2382
|
+
*/
|
|
2383
|
+
function validCliArgs(args) {
|
|
2384
|
+
const reportIdx = args.indexOf('--report-json');
|
|
2385
|
+
if (reportIdx >= 0 && (!args[reportIdx + 1] || args[reportIdx + 1].startsWith('-') || args.lastIndexOf('--report-json') !== reportIdx))
|
|
2386
|
+
throw new Error('--report-json requires one output path.');
|
|
2387
|
+
for (const flag of ['--branch', '--base-sha', '--repository']) {
|
|
2388
|
+
const idx = args.indexOf(flag);
|
|
2389
|
+
if (flag === '--repository') {
|
|
2390
|
+
for (const occurrence of args.flatMap((arg, i) => (arg === flag ? [i] : []))) {
|
|
2391
|
+
if (!args[occurrence + 1] || args[occurrence + 1].startsWith('-'))
|
|
2392
|
+
throw new Error(`${flag} requires an owner/name value.`);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
else if (idx >= 0 && (!args[idx + 1] || args[idx + 1].startsWith('-') || args.lastIndexOf(flag) !== idx))
|
|
2396
|
+
throw new Error(`${flag} requires one value.`);
|
|
2397
|
+
}
|
|
2398
|
+
const cmdArgs = args.filter((_, i) => i !== reportIdx + 1 || reportIdx < 0);
|
|
2399
|
+
const cmd = cmdArgs.find((arg) => !arg.startsWith('-')) ?? 'quality';
|
|
2400
|
+
const unknownFlags = cmdArgs.filter((arg) => arg.startsWith('-') && !CLI_FLAGS.has(arg));
|
|
2401
|
+
if (!CLI_CMDS.has(cmd) || unknownFlags.length)
|
|
2402
|
+
throw new Error(`Unknown quality CLI arguments: ${[...(!CLI_CMDS.has(cmd) ? [cmd] : []), ...unknownFlags].join(', ')}`);
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Read a single value following a validated CLI flag.
|
|
2406
|
+
*
|
|
2407
|
+
* @param args CLI arguments.
|
|
2408
|
+
* @param flag Flag whose following value is returned.
|
|
2409
|
+
* @return The flag value, or undefined when the flag was not supplied.
|
|
2410
|
+
*/
|
|
2411
|
+
function optVal(args, flag) {
|
|
2412
|
+
const idx = args.indexOf(flag);
|
|
2413
|
+
return idx < 0 ? undefined : args[idx + 1];
|
|
2414
|
+
}
|
|
2415
|
+
/** Read each occurrence of a repeated CLI option.
|
|
2416
|
+
* @param args Validated command-line arguments.
|
|
2417
|
+
* @param flag Repeated flag to read.
|
|
2418
|
+
* @return Values supplied after each occurrence of the flag.
|
|
2419
|
+
*/
|
|
2420
|
+
function optVals(args, flag) {
|
|
2421
|
+
return args.flatMap((arg, i) => (arg === flag && args[i + 1] ? [args[i + 1]] : []));
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Runs Prettier after applying the quality package's safe structural formatting pass.
|
|
2425
|
+
*
|
|
2426
|
+
* @param cfg Quality configuration defining formatter, scope, and print-width settings.
|
|
2427
|
+
* @param optsOrArgs Typed options or legacy CLI-style arguments.
|
|
2428
|
+
* @param args Additional CLI arguments when typed options are supplied.
|
|
2429
|
+
*
|
|
2430
|
+
* @return Nothing; formatter failures are reflected in the process exit code.
|
|
2431
|
+
*/
|
|
2432
|
+
export function runFormatter(cfg, optsOrArgs = { fix: true, all: false, strict: false }, args = []) {
|
|
2433
|
+
const legacyArgs = Array.isArray(optsOrArgs);
|
|
2434
|
+
const opts = legacyArgs ? { fix: optsOrArgs.includes('--write') || !optsOrArgs.includes('--check'), all: optsOrArgs.includes('--all'), strict: false } : optsOrArgs;
|
|
2435
|
+
const suppliedArgs = (legacyArgs ? optsOrArgs : args).filter((a) => !['--all', '--warn', '--strict'].includes(a));
|
|
2436
|
+
const hasMode = suppliedArgs.includes('--write') || suppliedArgs.includes('--check');
|
|
2437
|
+
const hasIgnoreMode = suppliedArgs.some((a) => a.startsWith('--ignore-unknown'));
|
|
2438
|
+
const hasLogLevel = suppliedArgs.includes('--log-level') || suppliedArgs.some((a) => a.startsWith('--log-level='));
|
|
2439
|
+
const suppliedTgts = suppliedArgs.filter((a, i) => a !== 'warn' || suppliedArgs[i - 1] !== '--log-level').filter((a) => !a.startsWith('-') && !a.endsWith('.astro'));
|
|
2440
|
+
const discoveredTgts = suppliedTgts.length ? [] : filesFor(cfg, opts.all).filter((f) => !f.endsWith('.astro'));
|
|
2441
|
+
const fmtArgs = [
|
|
2442
|
+
...(hasMode ? [] : [opts.fix ? '--write' : '--check']),
|
|
2443
|
+
...(hasIgnoreMode ? [] : ['--ignore-unknown']),
|
|
2444
|
+
...(opts.fix && !hasLogLevel ? ['--log-level=warn'] : []),
|
|
2445
|
+
...suppliedArgs,
|
|
2446
|
+
...(suppliedTgts.length ? [] : discoveredTgts),
|
|
2447
|
+
];
|
|
2448
|
+
const flags = fmtArgs.filter((a) => a.startsWith('-'));
|
|
2449
|
+
const tgts = suppliedTgts.length ? suppliedTgts : discoveredTgts;
|
|
2450
|
+
qualityHeader('format', `${opts.fix ? 'Fixing' : 'Checking'} formatting in ${tgts.length} source files`);
|
|
2451
|
+
const before = opts.fix ? snapshotFiles(cfg, tgts) : undefined;
|
|
2452
|
+
if (opts.fix) {
|
|
2453
|
+
// Explicit formatter targets are already the authoritative scope. Avoid
|
|
2454
|
+
// querying Git for a temporary/non-repository fixture when collapsing its
|
|
2455
|
+
// structural sources before Prettier.
|
|
2456
|
+
const knownFiles = suppliedTgts.length ? srcFiles(cfg) : filesFor(cfg, opts.all);
|
|
2457
|
+
const directTgts = suppliedTgts.map((t) => resolve(cfg.root, t));
|
|
2458
|
+
const collapseTgts = directTgts.length ? knownFiles.filter((f) => directTgts.some((t) => resolve(f) === t || resolve(f).startsWith(`${t}${sep}`))) : knownFiles;
|
|
2459
|
+
for (const file of collapseTgts)
|
|
2460
|
+
if (C_M_JS_TS_REGEX.test(file))
|
|
2461
|
+
collapseTypesAndObjects(file);
|
|
2462
|
+
}
|
|
2463
|
+
let failed = false;
|
|
2464
|
+
let completed = 0;
|
|
2465
|
+
qualityProgress('Prettier', completed, tgts.length);
|
|
2466
|
+
for (const tgtBatch of batches(tgts)) {
|
|
2467
|
+
const status = run(cfg, toolPath(cfg, cfg.tools?.prettier, 'prettier'), [...flags, ...tgtBatch], { quietSuccess: opts.fix });
|
|
2468
|
+
completed += tgtBatch.length;
|
|
2469
|
+
qualityProgress('Prettier', completed, tgts.length);
|
|
2470
|
+
if (status) {
|
|
2471
|
+
failed = true;
|
|
2472
|
+
process.exitCode = status;
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
if (before)
|
|
2476
|
+
console.error(` fixed ${changedFileCount(cfg, tgts, before)} files`);
|
|
2477
|
+
console.error(` ${failed ? '✗ failed' : '✓ passed'}`);
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Runs a configured TypeScript typecheck over either supplied files or the configured project.
|
|
2481
|
+
*
|
|
2482
|
+
* @param cfg Quality configuration containing compiler, root, and extension settings.
|
|
2483
|
+
* @param args Optional source files to typecheck in a scoped invocation.
|
|
2484
|
+
*
|
|
2485
|
+
* @return Nothing; compiler failures are reflected in the process exit code.
|
|
2486
|
+
*/
|
|
2487
|
+
export function runTypecheck(cfg, args = []) {
|
|
2488
|
+
if (cfg.typecheckCommand) {
|
|
2489
|
+
if (!args.length)
|
|
2490
|
+
runProjectTypecheck(cfg);
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
const tsc = cfg.tools?.tsc ?? 'tsc';
|
|
2494
|
+
// prettier-ignore
|
|
2495
|
+
const suppliedFiles = cfg.typecheckExtensions?.length
|
|
2496
|
+
? args.filter((f) => cfg.typecheckExtensions?.includes(f.slice(f.lastIndexOf('.'))) && (!cfg.typecheckRoots?.length || cfg.typecheckRoots.some((r) => f.startsWith(`${r}/`) || f.startsWith(`${r}\\`))))
|
|
2497
|
+
: args;
|
|
2498
|
+
if (args.length && !suppliedFiles.length)
|
|
2499
|
+
return;
|
|
2500
|
+
// prettier-ignore
|
|
2501
|
+
const typecheckFiles = cfg.typecheckExtensions?.length
|
|
2502
|
+
? srcFiles(cfg).filter((f) => cfg.typecheckExtensions?.includes(f.slice(f.lastIndexOf('.'))) && (!cfg.typecheckRoots?.length || cfg.typecheckRoots.some((r) => f.startsWith(`${r}/`) || f.startsWith(`${r}\\`))))
|
|
2503
|
+
: [];
|
|
2504
|
+
// prettier-ignore
|
|
2505
|
+
const compilerArgs = typecheckFiles.length
|
|
2506
|
+
? [
|
|
2507
|
+
'--noEmit', '--allowJs', '--checkJs', '--strict', '--skipLibCheck',
|
|
2508
|
+
'--module', 'ESNext', '--moduleResolution', 'Bundler', '--target',
|
|
2509
|
+
'ESNext', '--allowImportingTsExtensions',
|
|
2510
|
+
...(args.length ? suppliedFiles : typecheckFiles),
|
|
2511
|
+
]
|
|
2512
|
+
: ['--noEmit', '-p', cfg.tsconfig ?? 'tsconfig.json', ...suppliedFiles];
|
|
2513
|
+
qualityHeader('typecheck', `Checking ${args.length ? 'the selected source files' : 'the configured TypeScript project'}`);
|
|
2514
|
+
const result = spawnSync(toolPath(cfg, tsc, 'tsc'), compilerArgs, { cwd: cfg.root, stdio: ['inherit', 'pipe', 'pipe'], maxBuffer: TOOL_OUTPUT_MAX_BUFFER });
|
|
2515
|
+
printChildOutput(result.stdout);
|
|
2516
|
+
printChildOutput(result.stderr);
|
|
2517
|
+
if (result.status)
|
|
2518
|
+
process.exitCode = result.status ?? 1;
|
|
2519
|
+
console.error(` ${result.status ? '✗ failed' : '✓ passed'}`);
|
|
2520
|
+
}
|
|
2521
|
+
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url)))
|
|
2522
|
+
Promise.resolve(runQualityFromCfg()).catch((error) => {
|
|
2523
|
+
console.error(error instanceof Error ? error.message : error);
|
|
2524
|
+
process.exitCode = 1;
|
|
2525
|
+
});
|
|
2526
|
+
//# sourceMappingURL=runner.js.map
|