@kenkaiiii/ggcoder 5.75.1 → 5.77.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 +56 -40
- package/dist/app-sidecar/auth-routes.js +4 -1
- package/dist/app-sidecar/auth-routes.js.map +1 -1
- package/dist/app-sidecar/repo-polls.d.ts +5 -2
- package/dist/app-sidecar/repo-polls.js +20 -0
- package/dist/app-sidecar/repo-polls.js.map +1 -1
- package/dist/app-sidecar/route-context.d.ts +2 -0
- package/dist/app-sidecar.js +30 -0
- package/dist/app-sidecar.js.map +1 -1
- package/dist/chat-agents/jiwa.js +2 -0
- package/dist/chat-agents/jiwa.js.map +1 -1
- package/dist/chat-agents/memory.js +2 -0
- package/dist/chat-agents/memory.js.map +1 -1
- package/dist/cli/auth.js +13 -9
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli.js +8 -9
- package/dist/cli.js.map +1 -1
- package/dist/core/checklist-prompt.d.ts +9 -2
- package/dist/core/checklist-prompt.js +26 -7
- package/dist/core/checklist-prompt.js.map +1 -1
- package/dist/core/checklist-snapshot.js +1 -1
- package/dist/core/checklist-snapshot.js.map +1 -1
- package/dist/core/checklist-store.d.ts +5 -1
- package/dist/core/checklist-store.js +35 -4
- package/dist/core/checklist-store.js.map +1 -1
- package/dist/core/event-bus.d.ts +1 -1
- package/dist/core/event-bus.js +39 -13
- package/dist/core/event-bus.js.map +1 -1
- package/dist/core/extensions/plugin-bundles.js +4 -2
- package/dist/core/extensions/plugin-bundles.js.map +1 -1
- package/dist/core/hashline.d.ts +10 -8
- package/dist/core/hashline.js.map +1 -1
- package/dist/core/mcp/client.js +2 -0
- package/dist/core/mcp/client.js.map +1 -1
- package/dist/core/mcp/loopback.d.ts +2 -2
- package/dist/core/mcp/loopback.js +2 -2
- package/dist/core/mcp/shared-pool.js +4 -3
- package/dist/core/mcp/shared-pool.js.map +1 -1
- package/dist/core/persistent-shell.js +2 -1
- package/dist/core/persistent-shell.js.map +1 -1
- package/dist/core/process-manager.js +9 -1
- package/dist/core/process-manager.js.map +1 -1
- package/dist/core/project-health-scan.d.ts +109 -0
- package/dist/core/project-health-scan.js +673 -0
- package/dist/core/project-health-scan.js.map +1 -0
- package/dist/core/project-health-score.d.ts +41 -0
- package/dist/core/project-health-score.js +257 -0
- package/dist/core/project-health-score.js.map +1 -0
- package/dist/core/prompt-commands.js +15 -2
- package/dist/core/prompt-commands.js.map +1 -1
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-storage.d.ts +1 -0
- package/dist/core/session-storage.js +1 -1
- package/dist/core/session-storage.js.map +1 -1
- package/dist/core/subagent-manager.js +11 -6
- package/dist/core/subagent-manager.js.map +1 -1
- package/dist/core/subagent-receipt.js +3 -4
- package/dist/core/subagent-receipt.js.map +1 -1
- package/dist/core/ui-adoption.js +2 -0
- package/dist/core/ui-adoption.js.map +1 -1
- package/dist/core/ui-registry.js +5 -4
- package/dist/core/ui-registry.js.map +1 -1
- package/dist/core/verification-gate.js +4 -2
- package/dist/core/verification-gate.js.map +1 -1
- package/dist/core/verify-commands.js +2 -3
- package/dist/core/verify-commands.js.map +1 -1
- package/dist/modes/agent-home-mode.js +1 -4
- package/dist/modes/agent-home-mode.js.map +1 -1
- package/dist/modes/subagent-worker-mode.js +13 -8
- package/dist/modes/subagent-worker-mode.js.map +1 -1
- package/dist/tools/checklist.d.ts +5 -4
- package/dist/tools/checklist.js +17 -4
- package/dist/tools/checklist.js.map +1 -1
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/grep.js +3 -2
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/index.js +7 -5
- package/dist/tools/index.js.map +1 -1
- package/dist/ui/App.js +20 -26
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/components/InputArea.js +0 -2
- package/dist/ui/components/InputArea.js.map +1 -1
- package/dist/ui/components/ToolExecution.js +4 -3
- package/dist/ui/components/ToolExecution.js.map +1 -1
- package/dist/ui/hooks/useAgentLoop.js +7 -6
- package/dist/ui/hooks/useAgentLoop.js.map +1 -1
- package/dist/ui/sessions.js +1 -5
- package/dist/ui/sessions.js.map +1 -1
- package/dist/ui/utils/highlight.d.ts +0 -5
- package/dist/ui/utils/highlight.js +3 -49
- package/dist/ui/utils/highlight.js.map +1 -1
- package/dist/ui/utils/markdown-renderer.d.ts +6 -0
- package/dist/ui/utils/markdown-renderer.js +21 -5
- package/dist/ui/utils/markdown-renderer.js.map +1 -1
- package/dist/utils/process.js +3 -3
- package/dist/utils/process.js.map +1 -1
- package/package.json +5 -8
- package/dist/core/file-lock.d.ts +0 -1
- package/dist/core/file-lock.js +0 -3
- package/dist/core/file-lock.js.map +0 -1
- package/dist/core/oauth/pkce.d.ts +0 -1
- package/dist/core/oauth/pkce.js +0 -3
- package/dist/core/oauth/pkce.js.map +0 -1
- package/dist/ui/activity-phrases.d.ts +0 -10
- package/dist/ui/activity-phrases.js +0 -184
- package/dist/ui/activity-phrases.js.map +0 -1
- package/dist/ui/components/PlanApproval.d.ts +0 -8
- package/dist/ui/components/PlanApproval.js +0 -60
- package/dist/ui/components/PlanApproval.js.map +0 -1
- package/dist/ui/components/PlanBanner.d.ts +0 -6
- package/dist/ui/components/PlanBanner.js +0 -31
- package/dist/ui/components/PlanBanner.js.map +0 -1
- package/dist/ui/components/PlanProgress.d.ts +0 -7
- package/dist/ui/components/PlanProgress.js +0 -30
- package/dist/ui/components/PlanProgress.js.map +0 -1
- package/dist/ui/hooks/useMinDisplayTime.d.ts +0 -10
- package/dist/ui/hooks/useMinDisplayTime.js +0 -34
- package/dist/ui/hooks/useMinDisplayTime.js.map +0 -1
- package/dist/ui/hooks/useSessionManager.d.ts +0 -12
- package/dist/ui/hooks/useSessionManager.js +0 -43
- package/dist/ui/hooks/useSessionManager.js.map +0 -1
- package/dist/ui/hooks/useSlashCommands.d.ts +0 -6
- package/dist/ui/hooks/useSlashCommands.js +0 -11
- package/dist/ui/hooks/useSlashCommands.js.map +0 -1
- package/dist/ui/terminal-history-spacing.d.ts +0 -1
- package/dist/ui/terminal-history-spacing.js +0 -2
- package/dist/ui/terminal-history-spacing.js.map +0 -1
- package/dist/ui/utils/markdown-cache.d.ts +0 -10
- package/dist/ui/utils/markdown-cache.js +0 -58
- package/dist/ui/utils/markdown-cache.js.map +0 -1
- package/dist/ui/utils/token-to-ansi.d.ts +0 -8
- package/dist/ui/utils/token-to-ansi.js +0 -323
- package/dist/ui/utils/token-to-ansi.js.map +0 -1
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { runBackgroundGit } from "../utils/git.js";
|
|
4
|
+
// Raw, local signals behind the app's Project Health score. Read-only and
|
|
5
|
+
// bounded: files come from `git ls-files` (so ignored output never counts), no
|
|
6
|
+
// symlink is followed, and no project code or config is executed.
|
|
7
|
+
/** Source files longer than this count as oversized. */
|
|
8
|
+
export const OVERSIZED_LINES = 800;
|
|
9
|
+
/** Files bigger than this (outside Git LFS) count as large committed files. */
|
|
10
|
+
export const LARGE_FILE_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
/** How far back Git history counts toward a file's change count. */
|
|
12
|
+
export const HOTSPOT_DAYS = 90;
|
|
13
|
+
/** Fewest changes within {@link HOTSPOT_DAYS} that can make an oversized file a hotspot. */
|
|
14
|
+
export const HOTSPOT_CHANGES = 5;
|
|
15
|
+
/**
|
|
16
|
+
* A hotspot is among the repo's most-changed files (this share of all files
|
|
17
|
+
* changed in the period), so "changing often" means the same thing in a quiet
|
|
18
|
+
* repo and a busy one.
|
|
19
|
+
*/
|
|
20
|
+
export const HOTSPOT_TOP_SHARE = 0.02;
|
|
21
|
+
const MAX_HISTORY_COMMITS = 5_000;
|
|
22
|
+
/** Changes that make a file a hotspot, given every changed file's change count. */
|
|
23
|
+
export function hotspotThreshold(changeCounts) {
|
|
24
|
+
const sorted = [...changeCounts].sort((a, b) => b - a);
|
|
25
|
+
const cutoff = sorted[Math.floor(sorted.length * HOTSPOT_TOP_SHARE)] ?? 0;
|
|
26
|
+
return Math.max(HOTSPOT_CHANGES, cutoff);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* How much an oversized file's excess lines count: a big file that keeps
|
|
30
|
+
* changing is where bugs come from; a big file nobody touches costs little.
|
|
31
|
+
* null (no usable history) counts as ordinary.
|
|
32
|
+
*/
|
|
33
|
+
export function churnWeight(changes, threshold = HOTSPOT_CHANGES) {
|
|
34
|
+
if (changes === null)
|
|
35
|
+
return 1;
|
|
36
|
+
if (changes === 0)
|
|
37
|
+
return 0.5;
|
|
38
|
+
return changes >= threshold ? 2 : 1;
|
|
39
|
+
}
|
|
40
|
+
const MAX_LISTED = 50_000;
|
|
41
|
+
const MAX_SOURCE_FILES = 8_000;
|
|
42
|
+
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
43
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
44
|
+
const READ_CONCURRENCY = 16;
|
|
45
|
+
const KEPT_OFFENDERS = 20;
|
|
46
|
+
const TYPED_LANGUAGE_SHARE = 0.2;
|
|
47
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
48
|
+
// prettier-ignore
|
|
49
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
50
|
+
"astro", "bash", "c", "cc", "cjs", "clj", "cpp", "cs", "css", "cts", "dart", "ex", "exs",
|
|
51
|
+
"go", "h", "hpp", "html", "java", "jl", "js", "jsx", "kt", "kts", "less", "lua", "m", "mjs",
|
|
52
|
+
"mm", "mts", "php", "py", "r", "rb", "rs", "sass", "scala", "scss", "sh", "sql", "svelte",
|
|
53
|
+
"swift", "ts", "tsx", "vue", "zig", "zsh",
|
|
54
|
+
]);
|
|
55
|
+
const TS_EXTENSIONS = new Set(["ts", "tsx", "mts", "cts"]);
|
|
56
|
+
const JS_FAMILY = new Set(["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"]);
|
|
57
|
+
// prettier-ignore
|
|
58
|
+
const GENERATED_DIRS = new Set([
|
|
59
|
+
".next", ".nuxt", ".venv", "Pods", "__fixtures__", "__generated__", "__snapshots__", "build",
|
|
60
|
+
"coverage", "dist", "fixtures", "generated", "node_modules", "out", "target", "testdata",
|
|
61
|
+
"third-party", "third_party", "vendor", "venv",
|
|
62
|
+
]);
|
|
63
|
+
const GENERATED_NAME = /\.min\.[a-z]+$|\.bundle\.js$|\.generated\.|\.d\.[cm]?ts$|\.pb\.go$|_pb2\.py$|\.g\.dart$|\.freezed\.dart$/;
|
|
64
|
+
const TEST_DIR = /(?:^|\/)(?:__tests__|tests?|spec|e2e|cypress|playwright)\//;
|
|
65
|
+
// Script-style suites: `scripts/check-auth.mjs`, `tools/smoke.ts`, run from a
|
|
66
|
+
// package script or CI rather than a test runner.
|
|
67
|
+
const CHECK_SCRIPT = /(?:^|\/)(?:scripts|tools)\/(?:.+\/)?(?:check|smoke|e2e|verify)(?:[-_.][^/]*)?$/;
|
|
68
|
+
/** npm's placeholder for a project without tests. */
|
|
69
|
+
const NO_TEST_PLACEHOLDER = /no test specified/;
|
|
70
|
+
const TEST_NAME = /\.(?:test|spec)\.[^.]+$|^test_.+\.py$|_test\.(?:go|py|rb|exs?)$|Tests?\.(?:java|kt|cs|swift)$/;
|
|
71
|
+
const SECRET_KEY_NAME = /\.(?:pem|key|p12|pfx|jks|keystore)$|^id_(?:rsa|dsa|ecdsa|ed25519)$/i;
|
|
72
|
+
const ENV_FILE = /^\.env(?:\..+)?$/;
|
|
73
|
+
const ENV_EXAMPLE = /\.(?:example|sample|template|dist|defaults?)$/i;
|
|
74
|
+
const LOCKFILES = [
|
|
75
|
+
"pnpm-lock.yaml",
|
|
76
|
+
"package-lock.json",
|
|
77
|
+
"npm-shrinkwrap.json",
|
|
78
|
+
"yarn.lock",
|
|
79
|
+
"bun.lockb",
|
|
80
|
+
"bun.lock",
|
|
81
|
+
];
|
|
82
|
+
const CI_FILE = /^(?:\.github\/workflows\/[^/]+\.ya?ml|\.gitlab-ci\.yml|\.circleci\/config\.yml|azure-pipelines\.yml|Jenkinsfile|bitbucket-pipelines\.yml|\.buildkite\/pipeline\.ya?ml|\.travis\.yml)$/;
|
|
83
|
+
const LINT_CONFIG = /^(?:eslint\.config\.[cm]?[jt]s|\.eslintrc(?:\.(?:c?js|json|ya?ml))?|biome\.jsonc?|\.oxlintrc\.json|\.golangci\.(?:ya?ml|toml|json)|\.?ruff\.toml|\.flake8|\.?pylintrc|\.?clippy\.toml|\.rubocop\.yml|\.swiftlint\.yml|detekt\.yml|\.credo\.exs|deno\.jsonc?|analysis_options\.yaml|\.stylelintrc(?:\.\w+)?)$/;
|
|
84
|
+
const FORMAT_CONFIG = /^(?:\.prettierrc(?:\.\w+)?|prettier\.config\.[cm]?[jt]s|biome\.jsonc?|\.?rustfmt\.toml|\.clang-format|\.swift-format|\.swiftformat|\.?dprint\.json|deno\.jsonc?|\.?ruff\.toml|\.scalafmt\.conf|\.php-cs-fixer(?:\.dist)?\.php)$/;
|
|
85
|
+
/** Toolchains that ship their own linter and formatter. */
|
|
86
|
+
const BUILTIN_TOOLCHAIN = new Set(["go.mod", "Cargo.toml", "pubspec.yaml"]);
|
|
87
|
+
const TS_CONFIG = /^(?:tsconfig(?:\.[\w-]+)?\.json|jsconfig\.json)$/;
|
|
88
|
+
const PY_TYPE_CONFIG = new Set(["mypy.ini", ".mypy.ini", "pyrightconfig.json"]);
|
|
89
|
+
/**
|
|
90
|
+
* Keys with an unmistakable shape, so a match is almost never a false alarm.
|
|
91
|
+
* Deliberately narrow: no "long random string" heuristics, and no Google API
|
|
92
|
+
* keys (Firebase web keys are public by design).
|
|
93
|
+
*/
|
|
94
|
+
const SECRET_PATTERNS = [
|
|
95
|
+
{
|
|
96
|
+
label: "private key",
|
|
97
|
+
// The header plus a key body, so code that merely names the header is fine.
|
|
98
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----(?:\\n|\s)+[A-Za-z0-9+/]{40,}/,
|
|
99
|
+
},
|
|
100
|
+
{ label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
101
|
+
{
|
|
102
|
+
label: "GitHub token",
|
|
103
|
+
pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,})\b/,
|
|
104
|
+
},
|
|
105
|
+
{ label: "Anthropic API key", pattern: /\bsk-ant-[a-z]+\d*-[A-Za-z0-9_-]{60,}/ },
|
|
106
|
+
{
|
|
107
|
+
label: "OpenAI API key",
|
|
108
|
+
pattern: /\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,}|\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b/,
|
|
109
|
+
},
|
|
110
|
+
{ label: "OpenRouter API key", pattern: /\bsk-or-v1-[a-f0-9]{64}\b/ },
|
|
111
|
+
{ label: "Stripe live key", pattern: /\b[rs]k_live_[0-9A-Za-z]{24,}\b/ },
|
|
112
|
+
{ label: "Slack token", pattern: /\bxox[abprs]-[0-9]{10,13}-[0-9A-Za-z-]{10,}/ },
|
|
113
|
+
{
|
|
114
|
+
label: "Slack webhook",
|
|
115
|
+
pattern: /hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{24}/,
|
|
116
|
+
},
|
|
117
|
+
{ label: "npm token", pattern: /\bnpm_[A-Za-z0-9]{36}\b/ },
|
|
118
|
+
];
|
|
119
|
+
/** Docs-style stand-ins: AKIAIOSFODNN7EXAMPLE, sk-proj-your-key… */
|
|
120
|
+
const PLACEHOLDER_WORD = /example|placeholder|dummy|redacted|sample|your/i;
|
|
121
|
+
/** Filler like ghp_xxxx… or sk_live_0000…. Not for key bodies: real base64 has runs of A. */
|
|
122
|
+
const FILLER = /x{6,}|(.)\1{9,}/i;
|
|
123
|
+
/** Config files read only to look for keys (they don't count toward size or debt). */
|
|
124
|
+
const CONFIG_EXTENSIONS = new Set([
|
|
125
|
+
"cfg",
|
|
126
|
+
"conf",
|
|
127
|
+
"ini",
|
|
128
|
+
"json",
|
|
129
|
+
"jsonc",
|
|
130
|
+
"plist",
|
|
131
|
+
"properties",
|
|
132
|
+
"tf",
|
|
133
|
+
"tfvars",
|
|
134
|
+
"toml",
|
|
135
|
+
"xml",
|
|
136
|
+
"yaml",
|
|
137
|
+
"yml",
|
|
138
|
+
]);
|
|
139
|
+
const CONFIG_NAMES = new Set([".npmrc", ".pypirc", ".netrc", ".dockercfg", "Dockerfile"]);
|
|
140
|
+
const MAX_CONFIG_FILES = 2_000;
|
|
141
|
+
const MAX_CONFIG_BYTES = 512 * 1024;
|
|
142
|
+
/**
|
|
143
|
+
* The first real-looking key in `text`: its kind and 1-based line, never the
|
|
144
|
+
* value. Exported for tests.
|
|
145
|
+
*/
|
|
146
|
+
export function findSecret(text) {
|
|
147
|
+
let best = null;
|
|
148
|
+
for (const { label, pattern } of SECRET_PATTERNS) {
|
|
149
|
+
const global = new RegExp(pattern.source, "g");
|
|
150
|
+
for (const match of text.matchAll(global)) {
|
|
151
|
+
if (PLACEHOLDER_WORD.test(match[0]))
|
|
152
|
+
continue;
|
|
153
|
+
if (label !== "private key" && FILLER.test(match[0]))
|
|
154
|
+
continue;
|
|
155
|
+
if (!best || match.index < best.index)
|
|
156
|
+
best = { label, index: match.index };
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!best)
|
|
161
|
+
return null;
|
|
162
|
+
return { label: best.label, line: lineCount(text.slice(0, best.index + 1)) };
|
|
163
|
+
}
|
|
164
|
+
const TODO_MARKER = /\b(?:TODO|FIXME|HACK)\b/g;
|
|
165
|
+
const SUPPRESSION = /@ts-ignore|@ts-nocheck|eslint-disable|biome-ignore|#\s*type:\s*ignore|#\s*noqa|#\[allow\(|\/\/\s*nolint|@SuppressWarnings/g;
|
|
166
|
+
const ANY_TYPE = /:\s*any\b|\bas\s+any\b|<any>|\bany\[\]/g;
|
|
167
|
+
const GENERATED_HEADER = /@generated|DO NOT EDIT/;
|
|
168
|
+
function basename(rel) {
|
|
169
|
+
return rel.slice(rel.lastIndexOf("/") + 1);
|
|
170
|
+
}
|
|
171
|
+
function extension(rel) {
|
|
172
|
+
const name = basename(rel);
|
|
173
|
+
const dot = name.lastIndexOf(".");
|
|
174
|
+
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
175
|
+
}
|
|
176
|
+
function isGeneratedPath(rel) {
|
|
177
|
+
const parts = rel.split("/");
|
|
178
|
+
parts.pop();
|
|
179
|
+
return parts.some((part) => GENERATED_DIRS.has(part)) || GENERATED_NAME.test(basename(rel));
|
|
180
|
+
}
|
|
181
|
+
/** Exported for tests. */
|
|
182
|
+
export function isTestPath(rel) {
|
|
183
|
+
return TEST_DIR.test(rel) || TEST_NAME.test(basename(rel)) || CHECK_SCRIPT.test(rel);
|
|
184
|
+
}
|
|
185
|
+
/** A package.json `test` script that runs something (not npm's placeholder). */
|
|
186
|
+
function hasTestScript(manifests) {
|
|
187
|
+
for (const manifest of manifests) {
|
|
188
|
+
if (!manifest)
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(manifest);
|
|
192
|
+
if (typeof parsed !== "object" || parsed === null || !("scripts" in parsed))
|
|
193
|
+
continue;
|
|
194
|
+
const scripts = parsed.scripts;
|
|
195
|
+
if (typeof scripts !== "object" || scripts === null || !("test" in scripts))
|
|
196
|
+
continue;
|
|
197
|
+
const test = scripts.test;
|
|
198
|
+
if (typeof test === "string" && test.trim() && !NO_TEST_PLACEHOLDER.test(test))
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Unreadable or partial manifest: no signal.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
/** Exported for tests. */
|
|
208
|
+
export function isSecretPath(rel) {
|
|
209
|
+
const name = basename(rel);
|
|
210
|
+
if (ENV_FILE.test(name))
|
|
211
|
+
return !ENV_EXAMPLE.test(name);
|
|
212
|
+
// TLS test suites commit throwaway keys on purpose.
|
|
213
|
+
return SECRET_KEY_NAME.test(name) && !isTestPath(rel) && !isGeneratedPath(rel);
|
|
214
|
+
}
|
|
215
|
+
function count(text, pattern) {
|
|
216
|
+
return text.match(pattern)?.length ?? 0;
|
|
217
|
+
}
|
|
218
|
+
function lineCount(text) {
|
|
219
|
+
if (text.length === 0)
|
|
220
|
+
return 0;
|
|
221
|
+
let lines = 0;
|
|
222
|
+
for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1))
|
|
223
|
+
lines++;
|
|
224
|
+
return text.endsWith("\n") ? lines : lines + 1;
|
|
225
|
+
}
|
|
226
|
+
// `<` is left out: in TSX it is far more often `</tag>` than a comparison.
|
|
227
|
+
const REGEX_CAN_START = new Set("(,=:[!&|?{};+-*%>~^".split(""));
|
|
228
|
+
/**
|
|
229
|
+
* Splits JS/TS source into code (with string, regex and comment contents
|
|
230
|
+
* blanked) and the comment text. A small lexer, not a parser: template
|
|
231
|
+
* `${}` nesting and regex literals are handled; an unterminated quote ends at
|
|
232
|
+
* the line break (JSX text like `Don't` stays contained). Exported for tests.
|
|
233
|
+
*/
|
|
234
|
+
export function splitJsSource(text) {
|
|
235
|
+
let code = "";
|
|
236
|
+
const comments = [];
|
|
237
|
+
// Brace depth per open template expression; empty when not inside `${}`.
|
|
238
|
+
const templates = [];
|
|
239
|
+
let lastSignificant = "";
|
|
240
|
+
// Finds the next character that can open a string, comment, regex or brace.
|
|
241
|
+
// Sticky state (lastIndex), so one per call.
|
|
242
|
+
const plainRun = /[/'"`{}]/g;
|
|
243
|
+
let i = 0;
|
|
244
|
+
const n = text.length;
|
|
245
|
+
const blank = (from, to) => text.slice(from, to).replace(/[^\n]/g, " ");
|
|
246
|
+
const readTemplate = (start) => {
|
|
247
|
+
// From just after a backtick (or a closing `}` of an expression).
|
|
248
|
+
let j = start;
|
|
249
|
+
while (j < n) {
|
|
250
|
+
const c = text[j];
|
|
251
|
+
if (c === "\\")
|
|
252
|
+
j += 2;
|
|
253
|
+
else if (c === "`")
|
|
254
|
+
return j + 1;
|
|
255
|
+
else if (c === "$" && text[j + 1] === "{") {
|
|
256
|
+
templates.push(0);
|
|
257
|
+
return j + 2;
|
|
258
|
+
}
|
|
259
|
+
else
|
|
260
|
+
j++;
|
|
261
|
+
}
|
|
262
|
+
return n;
|
|
263
|
+
};
|
|
264
|
+
while (i < n) {
|
|
265
|
+
const c = text[i] ?? "";
|
|
266
|
+
const next = text[i + 1] ?? "";
|
|
267
|
+
if (c === "/" && next === "/") {
|
|
268
|
+
const end = text.indexOf("\n", i);
|
|
269
|
+
const stop = end === -1 ? n : end;
|
|
270
|
+
comments.push(text.slice(i, stop));
|
|
271
|
+
code += blank(i, stop);
|
|
272
|
+
i = stop;
|
|
273
|
+
}
|
|
274
|
+
else if (c === "/" && next === "*") {
|
|
275
|
+
const end = text.indexOf("*/", i + 2);
|
|
276
|
+
const stop = end === -1 ? n : end + 2;
|
|
277
|
+
comments.push(text.slice(i, stop));
|
|
278
|
+
code += blank(i, stop);
|
|
279
|
+
i = stop;
|
|
280
|
+
}
|
|
281
|
+
else if (c === "'" || c === '"') {
|
|
282
|
+
let j = i + 1;
|
|
283
|
+
while (j < n && text[j] !== c && text[j] !== "\n")
|
|
284
|
+
j += text[j] === "\\" ? 2 : 1;
|
|
285
|
+
const stop = Math.min(n, text[j] === c ? j + 1 : j);
|
|
286
|
+
code += blank(i, stop);
|
|
287
|
+
lastSignificant = c;
|
|
288
|
+
i = stop;
|
|
289
|
+
}
|
|
290
|
+
else if (c === "`") {
|
|
291
|
+
const stop = readTemplate(i + 1);
|
|
292
|
+
code += blank(i, stop);
|
|
293
|
+
lastSignificant = "`";
|
|
294
|
+
i = stop;
|
|
295
|
+
}
|
|
296
|
+
else if (c === "/" && (lastSignificant === "" || REGEX_CAN_START.has(lastSignificant))) {
|
|
297
|
+
let j = i + 1;
|
|
298
|
+
let inClass = false;
|
|
299
|
+
while (j < n && text[j] !== "\n") {
|
|
300
|
+
const r = text[j];
|
|
301
|
+
if (r === "\\")
|
|
302
|
+
j++;
|
|
303
|
+
else if (r === "[")
|
|
304
|
+
inClass = true;
|
|
305
|
+
else if (r === "]")
|
|
306
|
+
inClass = false;
|
|
307
|
+
else if (r === "/" && !inClass)
|
|
308
|
+
break;
|
|
309
|
+
j++;
|
|
310
|
+
}
|
|
311
|
+
const stop = text[j] === "/" ? j + 1 : i + 1;
|
|
312
|
+
code += blank(i, stop);
|
|
313
|
+
lastSignificant = "/x";
|
|
314
|
+
i = stop;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
if (templates.length > 0) {
|
|
318
|
+
const depth = templates.length - 1;
|
|
319
|
+
if (c === "{")
|
|
320
|
+
templates[depth] = (templates[depth] ?? 0) + 1;
|
|
321
|
+
else if (c === "}") {
|
|
322
|
+
if ((templates[depth] ?? 0) === 0) {
|
|
323
|
+
templates.pop();
|
|
324
|
+
const stop = readTemplate(i + 1);
|
|
325
|
+
code += blank(i, stop);
|
|
326
|
+
lastSignificant = "`";
|
|
327
|
+
i = stop;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
templates[depth] = (templates[depth] ?? 0) - 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (c === "{" || c === "}") {
|
|
334
|
+
code += c;
|
|
335
|
+
lastSignificant = c;
|
|
336
|
+
i++;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
// Plain code: copy the whole run up to the next character that can
|
|
340
|
+
// start a string, comment, regex or template brace in one slice. A
|
|
341
|
+
// per-character loop made a 2 MB file stall the event loop for ~70 ms.
|
|
342
|
+
plainRun.lastIndex = i;
|
|
343
|
+
const stop = plainRun.test(text) ? plainRun.lastIndex - 1 : n;
|
|
344
|
+
const end = Math.max(stop, i + 1);
|
|
345
|
+
code += text.slice(i, end);
|
|
346
|
+
for (let k = end - 1; k >= i; k--) {
|
|
347
|
+
const ch = text[k] ?? "";
|
|
348
|
+
if (ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") {
|
|
349
|
+
lastSignificant = ch;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
i = end;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return { code, comments: comments.join("\n") };
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Reads one file: size and debt facts for source files, and for every file a
|
|
360
|
+
* key check. Config files (`sourceFile` false) only get the key check.
|
|
361
|
+
*/
|
|
362
|
+
async function readFacts(file, rel, stat, sourceFile, signal) {
|
|
363
|
+
const skipped = {
|
|
364
|
+
mtimeMs: stat.mtimeMs,
|
|
365
|
+
size: stat.size,
|
|
366
|
+
lines: null,
|
|
367
|
+
todos: 0,
|
|
368
|
+
suppressions: 0,
|
|
369
|
+
anyTypes: 0,
|
|
370
|
+
secret: null,
|
|
371
|
+
};
|
|
372
|
+
let text;
|
|
373
|
+
try {
|
|
374
|
+
const buffer = await fs.readFile(file, { signal });
|
|
375
|
+
if (buffer.subarray(0, 8192).includes(0))
|
|
376
|
+
return skipped;
|
|
377
|
+
text = buffer.toString("utf-8");
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return skipped;
|
|
381
|
+
}
|
|
382
|
+
if (GENERATED_HEADER.test(text.slice(0, 2048)))
|
|
383
|
+
return skipped;
|
|
384
|
+
const secret = findSecret(text);
|
|
385
|
+
if (!sourceFile)
|
|
386
|
+
return { ...skipped, secret };
|
|
387
|
+
const ext = extension(rel);
|
|
388
|
+
// In JS-family files markers only count where they mean something: TODOs
|
|
389
|
+
// and suppressions in comments, `any` in code (not in strings or comments).
|
|
390
|
+
const split = JS_FAMILY.has(ext) ? splitJsSource(text) : { code: text, comments: text };
|
|
391
|
+
return {
|
|
392
|
+
mtimeMs: stat.mtimeMs,
|
|
393
|
+
size: stat.size,
|
|
394
|
+
lines: lineCount(text),
|
|
395
|
+
todos: count(split.comments, TODO_MARKER),
|
|
396
|
+
suppressions: count(split.comments, SUPPRESSION),
|
|
397
|
+
anyTypes: TS_EXTENSIONS.has(ext) ? count(split.code, ANY_TYPE) : 0,
|
|
398
|
+
secret,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
async function gitLines(args, cwd, signal) {
|
|
402
|
+
try {
|
|
403
|
+
const { stdout } = await runBackgroundGit(args, {
|
|
404
|
+
cwd,
|
|
405
|
+
signal,
|
|
406
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
407
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
408
|
+
});
|
|
409
|
+
return stdout;
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function zList(stdout) {
|
|
416
|
+
return stdout.split("\0").filter(Boolean);
|
|
417
|
+
}
|
|
418
|
+
async function readSmallText(file, signal) {
|
|
419
|
+
try {
|
|
420
|
+
const stat = await fs.lstat(file);
|
|
421
|
+
if (!stat.isFile() || stat.size > MAX_MANIFEST_BYTES)
|
|
422
|
+
return "";
|
|
423
|
+
return await fs.readFile(file, { encoding: "utf-8", signal });
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return "";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/** Paths among `candidates` that Git LFS stores (they don't bloat history). */
|
|
430
|
+
async function lfsPaths(top, candidates, signal) {
|
|
431
|
+
const lfs = new Set();
|
|
432
|
+
for (let i = 0; i < candidates.length; i += 100) {
|
|
433
|
+
const batch = candidates.slice(i, i + 100);
|
|
434
|
+
const out = await gitLines(["check-attr", "-z", "filter", "--", ...batch], top, signal);
|
|
435
|
+
if (out === null)
|
|
436
|
+
continue;
|
|
437
|
+
const parts = out.split("\0");
|
|
438
|
+
for (let j = 0; j + 2 < parts.length; j += 3) {
|
|
439
|
+
if (parts[j + 2] === "lfs")
|
|
440
|
+
lfs.add(parts[j] ?? "");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return lfs;
|
|
444
|
+
}
|
|
445
|
+
/** Recent change counts per file; null without history. */
|
|
446
|
+
async function changeCounts(top, signal) {
|
|
447
|
+
const out = await gitLines([
|
|
448
|
+
"log",
|
|
449
|
+
`--since=${HOTSPOT_DAYS}.days.ago`,
|
|
450
|
+
"--no-merges",
|
|
451
|
+
"--no-renames",
|
|
452
|
+
`-n${MAX_HISTORY_COMMITS}`,
|
|
453
|
+
// Each commit starts with \x01 (then a newline before its file list).
|
|
454
|
+
"--pretty=format:%x01",
|
|
455
|
+
"--name-only",
|
|
456
|
+
"-z",
|
|
457
|
+
], top, signal);
|
|
458
|
+
if (out === null)
|
|
459
|
+
return null;
|
|
460
|
+
const counts = new Map();
|
|
461
|
+
for (const token of zList(out)) {
|
|
462
|
+
const header = token.startsWith("\x01\n") ? 2 : token.startsWith("\x01") ? 1 : 0;
|
|
463
|
+
const rel = token.slice(header);
|
|
464
|
+
if (rel)
|
|
465
|
+
counts.set(rel, (counts.get(rel) ?? 0) + 1);
|
|
466
|
+
}
|
|
467
|
+
return { counts, threshold: hotspotThreshold(counts.values()) };
|
|
468
|
+
}
|
|
469
|
+
async function pool(items, run) {
|
|
470
|
+
let next = 0;
|
|
471
|
+
const worker = async () => {
|
|
472
|
+
while (next < items.length) {
|
|
473
|
+
const item = items[next++];
|
|
474
|
+
if (item !== undefined)
|
|
475
|
+
await run(item);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, items.length) }, worker));
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* A scanner with a per-file cache (by mtime + size), so a rescan of an
|
|
482
|
+
* unchanged project only stats files. Create one per polled project.
|
|
483
|
+
*/
|
|
484
|
+
export function createProjectHealthScanner() {
|
|
485
|
+
let cache = new Map();
|
|
486
|
+
// History only changes with HEAD (or as days pass), so reuse it per HEAD
|
|
487
|
+
// and day instead of re-walking the log every rescan.
|
|
488
|
+
let history = null;
|
|
489
|
+
return {
|
|
490
|
+
async scan(cwd, now, signal) {
|
|
491
|
+
const topOut = await gitLines(["rev-parse", "--show-toplevel"], cwd, signal);
|
|
492
|
+
const prefixOut = await gitLines(["rev-parse", "--show-prefix"], cwd, signal);
|
|
493
|
+
if (topOut === null || prefixOut === null || !topOut.trim())
|
|
494
|
+
return null;
|
|
495
|
+
const top = path.resolve(topOut.trim());
|
|
496
|
+
// The opened folder may be a subfolder of the repo: score its files, but
|
|
497
|
+
// look for repo-wide setup (CI, .gitignore, tool config) from the root.
|
|
498
|
+
const prefix = prefixOut.trim();
|
|
499
|
+
const [trackedOut, untrackedOut] = await Promise.all([
|
|
500
|
+
gitLines(["ls-files", "-z", "--cached"], top, signal),
|
|
501
|
+
gitLines(["ls-files", "-z", "--others", "--exclude-standard"], top, signal),
|
|
502
|
+
]);
|
|
503
|
+
if (trackedOut === null || untrackedOut === null || signal?.aborted)
|
|
504
|
+
return null;
|
|
505
|
+
const tracked = new Set(zList(trackedOut));
|
|
506
|
+
const all = [...new Set([...tracked, ...zList(untrackedOut)])].sort();
|
|
507
|
+
const truncated = all.length > MAX_LISTED;
|
|
508
|
+
const listed = truncated ? all.slice(0, MAX_LISTED) : all;
|
|
509
|
+
const listedSet = new Set(listed);
|
|
510
|
+
const names = new Set(listed.map(basename));
|
|
511
|
+
const inScope = listed.filter((rel) => rel.startsWith(prefix));
|
|
512
|
+
const secretFiles = inScope.filter(isSecretPath).map((rel) => ({
|
|
513
|
+
path: rel.slice(prefix.length),
|
|
514
|
+
tracked: tracked.has(rel),
|
|
515
|
+
found: null,
|
|
516
|
+
line: null,
|
|
517
|
+
}));
|
|
518
|
+
const sources = inScope.filter((rel) => SOURCE_EXTENSIONS.has(extension(rel)) && !isGeneratedPath(rel));
|
|
519
|
+
const scanned = sources.slice(0, MAX_SOURCE_FILES);
|
|
520
|
+
const sourceSet = new Set(scanned);
|
|
521
|
+
// Config files that can hold keys; lockfiles are huge and only hold hashes.
|
|
522
|
+
const configs = inScope
|
|
523
|
+
.filter((rel) => {
|
|
524
|
+
const name = basename(rel);
|
|
525
|
+
return ((CONFIG_EXTENSIONS.has(extension(rel)) || CONFIG_NAMES.has(name)) &&
|
|
526
|
+
!LOCKFILES.includes(name) &&
|
|
527
|
+
!isGeneratedPath(rel) &&
|
|
528
|
+
!isTestPath(rel));
|
|
529
|
+
})
|
|
530
|
+
.slice(0, MAX_CONFIG_FILES);
|
|
531
|
+
const nextCache = new Map();
|
|
532
|
+
const facts = new Map();
|
|
533
|
+
const big = [];
|
|
534
|
+
const scannedSet = new Set([...scanned, ...configs]);
|
|
535
|
+
await pool(inScope, async (rel) => {
|
|
536
|
+
if (signal?.aborted)
|
|
537
|
+
return;
|
|
538
|
+
const file = path.join(top, rel);
|
|
539
|
+
let stat;
|
|
540
|
+
try {
|
|
541
|
+
stat = await fs.lstat(file);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return; // deleted in the work tree
|
|
545
|
+
}
|
|
546
|
+
if (!stat.isFile())
|
|
547
|
+
return; // symlinks, submodules
|
|
548
|
+
if (tracked.has(rel) && stat.size > LARGE_FILE_BYTES)
|
|
549
|
+
big.push({ path: rel, bytes: stat.size });
|
|
550
|
+
if (!scannedSet.has(rel))
|
|
551
|
+
return;
|
|
552
|
+
const isSource = sourceSet.has(rel);
|
|
553
|
+
if (stat.size > (isSource ? MAX_SOURCE_BYTES : MAX_CONFIG_BYTES))
|
|
554
|
+
return;
|
|
555
|
+
const cached = cache.get(file);
|
|
556
|
+
const entry = cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size
|
|
557
|
+
? cached
|
|
558
|
+
: await readFacts(file, rel, stat, isSource, signal);
|
|
559
|
+
nextCache.set(file, entry);
|
|
560
|
+
facts.set(rel, entry);
|
|
561
|
+
});
|
|
562
|
+
if (signal?.aborted)
|
|
563
|
+
return null;
|
|
564
|
+
cache = nextCache;
|
|
565
|
+
const lfs = big.length > 0
|
|
566
|
+
? await lfsPaths(top, big.map((file) => file.path), signal)
|
|
567
|
+
: new Set();
|
|
568
|
+
const largeFiles = big
|
|
569
|
+
.filter((file) => !lfs.has(file.path))
|
|
570
|
+
.map((file) => ({ path: file.path.slice(prefix.length), bytes: file.bytes }))
|
|
571
|
+
.sort((a, b) => b.bytes - a.bytes || a.path.localeCompare(b.path));
|
|
572
|
+
const head = (await gitLines(["rev-parse", "HEAD"], top, signal))?.trim() ?? "";
|
|
573
|
+
const historyKey = `${head}:${now.toISOString().slice(0, 10)}`;
|
|
574
|
+
if (!head)
|
|
575
|
+
history = { key: historyKey, changes: null };
|
|
576
|
+
else if (history?.key !== historyKey) {
|
|
577
|
+
history = { key: historyKey, changes: await changeCounts(top, signal) };
|
|
578
|
+
}
|
|
579
|
+
const counts = history.changes?.counts ?? null;
|
|
580
|
+
const threshold = history.changes?.threshold ?? HOTSPOT_CHANGES;
|
|
581
|
+
let sourceLines = 0;
|
|
582
|
+
let weightedExcess = 0;
|
|
583
|
+
let hotspotCount = 0;
|
|
584
|
+
const oversized = [];
|
|
585
|
+
const debt = { todos: 0, suppressions: 0, anyTypes: 0, lines: 0 };
|
|
586
|
+
const namedSecrets = new Set(secretFiles.map((file) => file.path));
|
|
587
|
+
for (const [rel, entry] of facts) {
|
|
588
|
+
const shown = rel.slice(prefix.length);
|
|
589
|
+
// Test fixtures hold fake keys on purpose.
|
|
590
|
+
if (entry.secret && !isTestPath(rel) && !namedSecrets.has(shown)) {
|
|
591
|
+
secretFiles.push({
|
|
592
|
+
path: shown,
|
|
593
|
+
tracked: tracked.has(rel),
|
|
594
|
+
found: entry.secret.label,
|
|
595
|
+
line: entry.secret.line,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
// Tests and check scripts don't count toward size or debt: a long
|
|
599
|
+
// test file isn't the maintenance risk a long app file is. Config
|
|
600
|
+
// files were only read for keys.
|
|
601
|
+
if (entry.lines === null || isTestPath(rel) || !sourceSet.has(rel))
|
|
602
|
+
continue;
|
|
603
|
+
sourceLines += entry.lines;
|
|
604
|
+
if (entry.lines > OVERSIZED_LINES) {
|
|
605
|
+
const changes = counts ? (counts.get(rel) ?? 0) : null;
|
|
606
|
+
weightedExcess += (entry.lines - OVERSIZED_LINES) * churnWeight(changes, threshold);
|
|
607
|
+
if (changes !== null && changes >= threshold)
|
|
608
|
+
hotspotCount++;
|
|
609
|
+
oversized.push({ path: rel.slice(prefix.length), lines: entry.lines, changes });
|
|
610
|
+
}
|
|
611
|
+
debt.todos += entry.todos;
|
|
612
|
+
debt.suppressions += entry.suppressions;
|
|
613
|
+
debt.anyTypes += entry.anyTypes;
|
|
614
|
+
debt.lines += entry.lines;
|
|
615
|
+
}
|
|
616
|
+
const risk = (file) => (file.lines - OVERSIZED_LINES) * churnWeight(file.changes, threshold);
|
|
617
|
+
oversized.sort((a, b) => risk(b) - risk(a) || b.lines - a.lines || a.path.localeCompare(b.path));
|
|
618
|
+
const appSources = sources.filter((rel) => !isTestPath(rel));
|
|
619
|
+
// A language needs a type checker only when it's a real part of the
|
|
620
|
+
// project, not a stray build script.
|
|
621
|
+
const share = (match) => appSources.filter((rel) => match(extension(rel))).length / Math.max(1, appSources.length);
|
|
622
|
+
const hasTs = share((ext) => TS_EXTENSIONS.has(ext)) >= TYPED_LANGUAGE_SHARE;
|
|
623
|
+
const hasPy = share((ext) => ext === "py") >= TYPED_LANGUAGE_SHARE;
|
|
624
|
+
// Root manifests can declare tools without a config file.
|
|
625
|
+
const manifests = [...new Set(["", prefix])];
|
|
626
|
+
const packageJsons = await Promise.all(manifests.map((dir) => readSmallText(path.join(top, dir, "package.json"), signal)));
|
|
627
|
+
const packageJson = packageJsons.join("\n");
|
|
628
|
+
const python = (await Promise.all(manifests.flatMap((dir) => [
|
|
629
|
+
readSmallText(path.join(top, dir, "pyproject.toml"), signal),
|
|
630
|
+
readSmallText(path.join(top, dir, "setup.cfg"), signal),
|
|
631
|
+
]))).join("\n");
|
|
632
|
+
const builtin = [...names].some((name) => BUILTIN_TOOLCHAIN.has(name));
|
|
633
|
+
const hasLint = builtin ||
|
|
634
|
+
[...names].some((name) => LINT_CONFIG.test(name)) ||
|
|
635
|
+
/"eslintConfig"\s*:|\b(?:eslint|biome|oxlint|xo)\b/.test(packageJson) ||
|
|
636
|
+
/\[tool\.(?:ruff|pylint|flake8)|\[flake8\]/.test(python);
|
|
637
|
+
const hasFormat = builtin ||
|
|
638
|
+
[...names].some((name) => FORMAT_CONFIG.test(name)) ||
|
|
639
|
+
/"prettier"\s*:|\b(?:prettier|biome|dprint)\b/.test(packageJson) ||
|
|
640
|
+
/\[tool\.(?:ruff|black)/.test(python);
|
|
641
|
+
const tsOk = !hasTs || [...names].some((name) => TS_CONFIG.test(name));
|
|
642
|
+
const pyOk = !hasPy ||
|
|
643
|
+
[...names].some((name) => PY_TYPE_CONFIG.has(name)) ||
|
|
644
|
+
/\[tool\.(?:mypy|pyright|basedpyright)\]|\[mypy\]/.test(python);
|
|
645
|
+
const hasPackage = listedSet.has(`${prefix}package.json`);
|
|
646
|
+
const hasLock = LOCKFILES.some((lock) => tracked.has(`${prefix}${lock}`) || tracked.has(lock));
|
|
647
|
+
return {
|
|
648
|
+
truncated: truncated || sources.length > MAX_SOURCE_FILES,
|
|
649
|
+
sourceFiles: [...facts].filter(([rel, entry]) => entry.lines !== null && !isTestPath(rel) && sourceSet.has(rel)).length,
|
|
650
|
+
sourceLines,
|
|
651
|
+
oversized: oversized.slice(0, KEPT_OFFENDERS),
|
|
652
|
+
oversizedCount: oversized.length,
|
|
653
|
+
hotspotCount,
|
|
654
|
+
oversizedWeightedExcess: weightedExcess,
|
|
655
|
+
// Committed first: those are the incidents.
|
|
656
|
+
secretFiles: secretFiles
|
|
657
|
+
.sort((a, b) => Number(b.tracked) - Number(a.tracked) || a.path.localeCompare(b.path))
|
|
658
|
+
.slice(0, KEPT_OFFENDERS),
|
|
659
|
+
largeFiles: largeFiles.slice(0, KEPT_OFFENDERS),
|
|
660
|
+
largeFileCount: largeFiles.length,
|
|
661
|
+
hasGitignore: listedSet.has(".gitignore"),
|
|
662
|
+
missingLockfile: hasPackage && !hasLock,
|
|
663
|
+
hasTests: sources.some(isTestPath) || hasTestScript(packageJsons),
|
|
664
|
+
hasLint,
|
|
665
|
+
hasFormat,
|
|
666
|
+
hasTypecheck: hasTs || hasPy ? tsOk && pyOk : null,
|
|
667
|
+
hasCIWorkflow: listed.some((rel) => CI_FILE.test(rel)),
|
|
668
|
+
debt,
|
|
669
|
+
};
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
//# sourceMappingURL=project-health-scan.js.map
|