devkit-quality 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/README.md +38 -15
- package/dist/cli.js +71 -49
- package/dist/cliLogic.js +39 -4
- package/dist/discovery.js +175 -17
- package/dist/glob.js +5 -1
- package/dist/ignore.js +14 -5
- package/dist/moduleGraph.js +4 -1
- package/dist/reporters.js +180 -92
- package/dist/rules/deadCode.js +4 -4
- package/dist/rules/dependencies.js +126 -53
- package/dist/rules/duplication.js +24 -9
- package/dist/rules/errorHandling.js +3 -1
- package/dist/rules/hygiene.js +11 -2
- package/dist/rules/security.js +20 -6
- package/dist/scanner.js +90 -27
- package/dist/scoring.js +53 -15
- package/dist/suppressions.js +28 -4
- package/dist/terminal.js +151 -37
- package/package.json +11 -3
package/dist/discovery.js
CHANGED
|
@@ -3,58 +3,205 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
6
|
+
exports.IGNORE_DIRS = exports.SOURCE_EXTENSIONS = void 0;
|
|
7
7
|
exports.isRelevantFile = isRelevantFile;
|
|
8
8
|
exports.isTestFile = isTestFile;
|
|
9
9
|
exports.isGeneratedFile = isGeneratedFile;
|
|
10
10
|
exports.isGeneratedByContent = isGeneratedByContent;
|
|
11
11
|
exports.filterScannedFiles = filterScannedFiles;
|
|
12
|
+
exports.discoverFiles = discoverFiles;
|
|
12
13
|
exports.walkDirectory = walkDirectory;
|
|
14
|
+
exports.toRelative = toRelative;
|
|
13
15
|
exports.readFileSafe = readFileSafe;
|
|
14
16
|
exports.countLines = countLines;
|
|
17
|
+
const node_child_process_1 = require("node:child_process");
|
|
15
18
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
16
19
|
const node_path_1 = __importDefault(require("node:path"));
|
|
17
20
|
const glob_1 = require("./glob");
|
|
18
|
-
|
|
19
|
-
exports.
|
|
20
|
-
|
|
21
|
+
const ignore_1 = require("./ignore");
|
|
22
|
+
exports.SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts']);
|
|
23
|
+
// Tool caches and framework build output: never source, wherever they appear.
|
|
24
|
+
exports.IGNORE_DIRS = new Set([
|
|
25
|
+
'node_modules', '.git', '.devkit', '.next', '.nuxt', '.svelte-kit', '.angular', '.turbo', '.cache',
|
|
26
|
+
'.vercel', '.output', '.parcel-cache', '.yarn', '.pnpm-store', '__pycache__', '.venv', '.idea'
|
|
27
|
+
]);
|
|
28
|
+
// Build-output names that are only build output when they sit directly in a package root
|
|
29
|
+
// (next to a package.json). `src/build/` or `src/commands/out/` are ordinary source folders.
|
|
30
|
+
// Deliberately excludes `vendor`: vendored/checked-in third-party code is sometimes kept out of
|
|
31
|
+
// lint configs, but it is still real, committed content that must stay visible to a security scan.
|
|
32
|
+
const PACKAGE_OUTPUT_DIRS = new Set(['dist', 'build', 'out', 'coverage']);
|
|
33
|
+
const GENERATED_DIRS = new Set(['generated', '__generated__']);
|
|
34
|
+
const MAX_SOURCE_FILE_BYTES = 1_500_000;
|
|
35
|
+
const MAX_TEXT_FILE_BYTES = 1_000_000;
|
|
36
|
+
// Files whose contents are machine-produced noise for a secret scan.
|
|
37
|
+
const NON_SCANNABLE_TEXT = /(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|composer\.lock|Gemfile\.lock|Cargo\.lock|poetry\.lock|go\.sum)$|\.(?:map|svg|snap|lock)$/i;
|
|
38
|
+
const BINARY_EXTENSIONS = /\.(?:png|jpe?g|gif|webp|ico|bmp|tiff?|avif|pdf|zip|gz|tgz|bz2|xz|7z|rar|jar|war|class|exe|dll|so|dylib|o|a|wasm|woff2?|ttf|otf|eot|mp[34]|mov|avi|webm|wav|ogg|flac|psd|sketch|fig|db|sqlite3?|bin|dat|pyc)$/i;
|
|
21
39
|
function isRelevantFile(filePath) {
|
|
22
|
-
|
|
23
|
-
return exports.SOURCE_EXTENSIONS.has(ext);
|
|
40
|
+
return exports.SOURCE_EXTENSIONS.has(node_path_1.default.extname(filePath).toLowerCase());
|
|
24
41
|
}
|
|
25
42
|
function isTestFile(filePath) {
|
|
26
|
-
const lower = filePath.toLowerCase();
|
|
27
|
-
return
|
|
43
|
+
const lower = filePath.replace(/\\/g, '/').toLowerCase();
|
|
44
|
+
return (/\.(?:test|spec|e2e|stories)\.[cm]?[jt]sx?$/.test(lower) ||
|
|
45
|
+
/(?:^|\/)(?:__tests__|__mocks__|__fixtures__|tests?|e2e|cypress|playwright|fixtures?)\//.test(lower));
|
|
28
46
|
}
|
|
29
47
|
function isGeneratedFile(filePath) {
|
|
30
|
-
const
|
|
31
|
-
const segments = lower.split('/');
|
|
32
|
-
const generatedDirectories = new Set(['generated', 'dist', 'build', 'coverage', '.next', '.nuxt', '.svelte-kit', '.angular', 'out']);
|
|
48
|
+
const segments = filePath.replace(/\\/g, '/').toLowerCase().split('/');
|
|
33
49
|
const basename = segments[segments.length - 1] ?? '';
|
|
34
|
-
return segments.some((segment) =>
|
|
50
|
+
return (segments.some((segment) => exports.IGNORE_DIRS.has(segment) || GENERATED_DIRS.has(segment)) ||
|
|
51
|
+
/\.d\.[cm]?ts$/.test(basename) ||
|
|
52
|
+
/[.-]min\.[cm]?js$/.test(basename) ||
|
|
53
|
+
/\.bundle\.[cm]?js$/.test(basename));
|
|
54
|
+
}
|
|
55
|
+
/** The comment block (and blank lines) at the very top of a file, before any code. */
|
|
56
|
+
function leadingCommentBlock(text) {
|
|
57
|
+
const header = [];
|
|
58
|
+
for (const line of text.split(/\r\n|\r|\n/, 40)) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
if (trimmed && !/^(?:\/\/|\/\*|\*|#!|'use |"use )/.test(trimmed))
|
|
61
|
+
break;
|
|
62
|
+
header.push(line);
|
|
63
|
+
}
|
|
64
|
+
return header.join('\n');
|
|
35
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Only the leading comment block is inspected: a "do not edit" note inside a hand-written
|
|
68
|
+
* file must not hide that file from the scan.
|
|
69
|
+
*/
|
|
36
70
|
function isGeneratedByContent(text) {
|
|
37
|
-
|
|
71
|
+
const header = leadingCommentBlock(text);
|
|
72
|
+
// `DO NOT EDIT` is matched case-sensitively: generators emit it uppercase, while a human's
|
|
73
|
+
// "do not edit this by hand" note is not a generated-file marker.
|
|
74
|
+
if (/\bDO NOT EDIT\b/.test(header) || /@generated\b|\bauto-?generated\b|\bGENERATED FILE\b|\bThis file (?:is|was) (?:automatically )?generated\b/i.test(header)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (/^\/\/[#@]\s*sourceMappingURL=/m.test(text.slice(-400)))
|
|
78
|
+
return true;
|
|
79
|
+
return looksMinified(text);
|
|
80
|
+
}
|
|
81
|
+
function looksMinified(text) {
|
|
82
|
+
if (text.length < 5000)
|
|
83
|
+
return false;
|
|
84
|
+
const lines = text.split('\n');
|
|
85
|
+
return text.length / lines.length > 300;
|
|
86
|
+
}
|
|
87
|
+
function isBinaryContent(buffer) {
|
|
88
|
+
const sample = buffer.subarray(0, 8000);
|
|
89
|
+
return sample.includes(0);
|
|
90
|
+
}
|
|
91
|
+
function listGitFiles(projectRoot) {
|
|
92
|
+
try {
|
|
93
|
+
const output = (0, node_child_process_1.execFileSync)('git', ['-C', projectRoot, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
|
|
94
|
+
encoding: 'utf8',
|
|
95
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
96
|
+
maxBuffer: 256 * 1024 * 1024
|
|
97
|
+
});
|
|
98
|
+
const listed = output.split('\0').filter(Boolean);
|
|
99
|
+
// An empty listing usually means the folder is itself ignored by an enclosing repository.
|
|
100
|
+
if (listed.length === 0)
|
|
101
|
+
return null;
|
|
102
|
+
return listed
|
|
103
|
+
.map((relative) => node_path_1.default.join(projectRoot, relative))
|
|
104
|
+
.filter((file) => {
|
|
105
|
+
try {
|
|
106
|
+
return node_fs_1.default.statSync(file).isFile();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false; // tracked but deleted in the working tree
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Drops files inside ignored directories, including build output located in a package root. */
|
|
118
|
+
function isInIgnoredDirectory(relativePath, packageRoots) {
|
|
119
|
+
const segments = relativePath.split('/');
|
|
120
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
121
|
+
const segment = segments[index];
|
|
122
|
+
if (exports.IGNORE_DIRS.has(segment))
|
|
123
|
+
return true;
|
|
124
|
+
if (PACKAGE_OUTPUT_DIRS.has(segment) && packageRoots.has(segments.slice(0, index).join('/')))
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
function collectPackageRoots(relativeFiles) {
|
|
130
|
+
const roots = new Set(['']);
|
|
131
|
+
for (const relative of relativeFiles) {
|
|
132
|
+
if (relative === 'package.json' || relative.endsWith('/package.json')) {
|
|
133
|
+
roots.add(relative.slice(0, -'package.json'.length).replace(/\/$/, ''));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return roots;
|
|
38
137
|
}
|
|
39
138
|
function filterScannedFiles(allFiles, projectRoot, config, ignorePatterns) {
|
|
40
139
|
const files = [];
|
|
41
140
|
const generatedFiles = [];
|
|
141
|
+
const tooLarge = [];
|
|
42
142
|
for (const file of allFiles) {
|
|
43
143
|
if (!isRelevantFile(file))
|
|
44
144
|
continue;
|
|
45
|
-
const relative =
|
|
145
|
+
const relative = toRelative(projectRoot, file);
|
|
46
146
|
if (!(0, glob_1.matchesAnyGlob)(relative, config.scan.include))
|
|
47
147
|
continue;
|
|
48
148
|
if ((0, glob_1.matchesAnyGlob)(relative, config.scan.exclude) || (0, glob_1.matchesAnyGlob)(relative, ignorePatterns))
|
|
49
149
|
continue;
|
|
50
|
-
|
|
51
|
-
|
|
150
|
+
if (fileSize(file) > MAX_SOURCE_FILE_BYTES) {
|
|
151
|
+
tooLarge.push(relative);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (isGeneratedFile(relative) || isGeneratedByContent(readFileSafe(file))) {
|
|
52
155
|
generatedFiles.push(relative);
|
|
53
156
|
continue;
|
|
54
157
|
}
|
|
55
158
|
files.push(file);
|
|
56
159
|
}
|
|
57
|
-
return { files, generatedFiles };
|
|
160
|
+
return { files, generatedFiles, tooLarge };
|
|
161
|
+
}
|
|
162
|
+
function discoverFiles(projectRoot, config) {
|
|
163
|
+
const gitFiles = listGitFiles(projectRoot);
|
|
164
|
+
const ignorePatterns = (0, ignore_1.loadIgnorePatterns)(projectRoot, { includeGitignore: gitFiles === null });
|
|
165
|
+
const candidates = gitFiles ?? walkDirectory(projectRoot);
|
|
166
|
+
const relativeCandidates = candidates.map((file) => toRelative(projectRoot, file));
|
|
167
|
+
const packageRoots = collectPackageRoots(relativeCandidates);
|
|
168
|
+
const allFiles = candidates
|
|
169
|
+
.filter((_, index) => !isInIgnoredDirectory(relativeCandidates[index], packageRoots))
|
|
170
|
+
.filter((file) => !(0, glob_1.matchesAnyGlob)(toRelative(projectRoot, file), ignorePatterns))
|
|
171
|
+
.sort();
|
|
172
|
+
const { files, generatedFiles, tooLarge } = filterScannedFiles(allFiles, projectRoot, config, ignorePatterns);
|
|
173
|
+
const textFiles = [];
|
|
174
|
+
let binary = 0;
|
|
175
|
+
for (const file of allFiles) {
|
|
176
|
+
if (isRelevantFile(file))
|
|
177
|
+
continue;
|
|
178
|
+
const relative = toRelative(projectRoot, file);
|
|
179
|
+
if ((0, glob_1.matchesAnyGlob)(relative, config.scan.exclude))
|
|
180
|
+
continue;
|
|
181
|
+
if (BINARY_EXTENSIONS.test(file)) {
|
|
182
|
+
binary += 1;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (NON_SCANNABLE_TEXT.test(relative))
|
|
186
|
+
continue;
|
|
187
|
+
if (fileSize(file) > MAX_TEXT_FILE_BYTES) {
|
|
188
|
+
tooLarge.push(relative);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
let buffer;
|
|
192
|
+
try {
|
|
193
|
+
buffer = node_fs_1.default.readFileSync(file);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (isBinaryContent(buffer)) {
|
|
199
|
+
binary += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
textFiles.push(file);
|
|
203
|
+
}
|
|
204
|
+
return { allFiles, files, textFiles, generatedFiles, skipped: { tooLarge, binary }, method: gitFiles ? 'git' : 'filesystem' };
|
|
58
205
|
}
|
|
59
206
|
function walkDirectory(rootDir) {
|
|
60
207
|
const allFiles = [];
|
|
@@ -83,6 +230,17 @@ function walkDirectory(rootDir) {
|
|
|
83
230
|
visit(rootDir);
|
|
84
231
|
return allFiles;
|
|
85
232
|
}
|
|
233
|
+
function toRelative(projectRoot, file) {
|
|
234
|
+
return node_path_1.default.relative(projectRoot, file).replace(/\\/g, '/');
|
|
235
|
+
}
|
|
236
|
+
function fileSize(filePath) {
|
|
237
|
+
try {
|
|
238
|
+
return node_fs_1.default.statSync(filePath).size;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
86
244
|
function readFileSafe(filePath) {
|
|
87
245
|
try {
|
|
88
246
|
return node_fs_1.default.readFileSync(filePath, 'utf8');
|
package/dist/glob.js
CHANGED
|
@@ -7,11 +7,15 @@ function globToRegExp(pattern) {
|
|
|
7
7
|
for (let i = 0; i < pattern.length; i += 1) {
|
|
8
8
|
const char = pattern[i];
|
|
9
9
|
if (char === '*' && pattern[i + 1] === '*') {
|
|
10
|
-
regex += '.*';
|
|
11
10
|
i += 1;
|
|
12
11
|
if (pattern[i + 1] === '/') {
|
|
12
|
+
// `**/` spans zero or more whole directories, so `**/lib` must not match `mylib`.
|
|
13
|
+
regex += '(?:.*/)?';
|
|
13
14
|
i += 1;
|
|
14
15
|
}
|
|
16
|
+
else {
|
|
17
|
+
regex += '.*';
|
|
18
|
+
}
|
|
15
19
|
continue;
|
|
16
20
|
}
|
|
17
21
|
if (char === '*') {
|
package/dist/ignore.js
CHANGED
|
@@ -6,9 +6,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.loadIgnorePatterns = loadIgnorePatterns;
|
|
7
7
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
8
|
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Converts .gitignore-style lines into globs. A pattern with a leading or inner slash is
|
|
11
|
+
* anchored to the project root (`/lib` ignores `lib/`, not `src/lib/`); a bare name matches
|
|
12
|
+
* at any depth. Negations (`!pattern`) are not supported here — when the project is a git
|
|
13
|
+
* repository, git itself applies .gitignore and this is only used for .devkitignore.
|
|
14
|
+
*/
|
|
15
|
+
function loadIgnorePatterns(projectRoot, options = {}) {
|
|
16
|
+
const includeGitignore = options.includeGitignore ?? true;
|
|
10
17
|
const patterns = new Set();
|
|
11
|
-
|
|
18
|
+
const sources = includeGitignore ? ['.gitignore', '.devkitignore'] : ['.devkitignore'];
|
|
19
|
+
for (const name of sources) {
|
|
12
20
|
const file = node_path_1.default.join(projectRoot, name);
|
|
13
21
|
if (!node_fs_1.default.existsSync(file))
|
|
14
22
|
continue;
|
|
@@ -16,12 +24,13 @@ function loadIgnorePatterns(projectRoot) {
|
|
|
16
24
|
const line = rawLine.trim();
|
|
17
25
|
if (!line || line.startsWith('#') || line.startsWith('!'))
|
|
18
26
|
continue;
|
|
27
|
+
const anchored = line.startsWith('/') || line.replace(/\/$/, '').includes('/');
|
|
19
28
|
const normalized = line.replace(/^\//, '').replace(/\/$/, '');
|
|
20
29
|
if (!normalized)
|
|
21
30
|
continue;
|
|
22
|
-
|
|
23
|
-
patterns.add(
|
|
24
|
-
patterns.add(
|
|
31
|
+
const prefix = anchored || normalized.startsWith('**/') ? '' : '**/';
|
|
32
|
+
patterns.add(`${prefix}${normalized}`);
|
|
33
|
+
patterns.add(`${prefix}${normalized}/**`);
|
|
25
34
|
}
|
|
26
35
|
}
|
|
27
36
|
return [...patterns];
|
package/dist/moduleGraph.js
CHANGED
|
@@ -12,6 +12,7 @@ const parse_1 = require("./ast/parse");
|
|
|
12
12
|
const walk_1 = require("./ast/walk");
|
|
13
13
|
const glob_1 = require("./glob");
|
|
14
14
|
const moduleResolution_1 = require("./moduleResolution");
|
|
15
|
+
const TOOL_CONFIG_FILE = /(?:^|\/)(?:[\w.-]+\.config|\.[\w-]+rc)\.[cm]?[jt]sx?$/;
|
|
15
16
|
function extractImportsAndExports(file, projectRoot, sourceFile, fileSet, resolutionContext) {
|
|
16
17
|
const imports = [];
|
|
17
18
|
const exports = [];
|
|
@@ -231,7 +232,9 @@ function resolveEntryPoints(projectRoot, files, packageJson, config) {
|
|
|
231
232
|
// Keep the parameter explicit: framework and custom entry conventions are config-dependent.
|
|
232
233
|
void config;
|
|
233
234
|
for (const file of files) {
|
|
234
|
-
|
|
235
|
+
const relative = node_path_1.default.relative(projectRoot, file).replace(/\\/g, '/');
|
|
236
|
+
// Tests and tool config files (vite.config.ts, eslint.config.mjs, ...) are loaded by tools, not imported.
|
|
237
|
+
if ((0, discovery_1.isTestFile)(relative) || TOOL_CONFIG_FILE.test(relative)) {
|
|
235
238
|
entryPoints.add(file);
|
|
236
239
|
}
|
|
237
240
|
}
|