@reallavo/clonecheck 0.1.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/.env.example +2 -0
- package/.gitattributes +1 -0
- package/.github/workflows/ci.yml +24 -0
- package/CONTRIBUTING.md +22 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/action/README.md +34 -0
- package/action/action.yml +50 -0
- package/clonecheck.config.json +5 -0
- package/docs/checks.md +44 -0
- package/docs/config.md +36 -0
- package/docs/scoring.md +20 -0
- package/eslint.config.js +29 -0
- package/examples/docker-compose-missing-env/.env.example +1 -0
- package/examples/docker-compose-missing-env/README.md +5 -0
- package/examples/docker-compose-missing-env/docker-compose.yml +12 -0
- package/examples/node-basic/.env.example +1 -0
- package/examples/node-basic/README.md +8 -0
- package/examples/node-basic/package.json +15 -0
- package/examples/node-basic/pnpm-lock.yaml +5 -0
- package/examples/node-basic/src-index.ts +1 -0
- package/examples/node-missing-env/.env.example +1 -0
- package/examples/node-missing-env/README.md +8 -0
- package/examples/node-missing-env/package.json +11 -0
- package/examples/node-missing-env/pnpm-lock.yaml +5 -0
- package/examples/node-missing-env/src/app.ts +14 -0
- package/examples/python-basic/.env.example +1 -0
- package/examples/python-basic/README.md +8 -0
- package/examples/python-basic/app.py +6 -0
- package/examples/python-basic/requirements.txt +1 -0
- package/package.json +40 -0
- package/packages/cli/LICENSE +21 -0
- package/packages/cli/README.md +15 -0
- package/packages/cli/package.json +55 -0
- package/packages/cli/src/index.ts +186 -0
- package/packages/cli/tsconfig.json +11 -0
- package/packages/core/LICENSE +21 -0
- package/packages/core/README.md +11 -0
- package/packages/core/package.json +51 -0
- package/packages/core/src/checks/ciPresence.ts +35 -0
- package/packages/core/src/checks/dockerComposeEnv.ts +61 -0
- package/packages/core/src/checks/envExample.ts +31 -0
- package/packages/core/src/checks/helpers.ts +54 -0
- package/packages/core/src/checks/index.ts +24 -0
- package/packages/core/src/checks/packageManagerConsistency.ts +65 -0
- package/packages/core/src/checks/portDocumentation.ts +62 -0
- package/packages/core/src/checks/projectFiles.ts +77 -0
- package/packages/core/src/checks/readmeCommands.ts +87 -0
- package/packages/core/src/checks/scriptsAvailability.ts +69 -0
- package/packages/core/src/config.ts +96 -0
- package/packages/core/src/detectors/dockerCompose.ts +62 -0
- package/packages/core/src/detectors/env.ts +189 -0
- package/packages/core/src/detectors/packageManager.ts +54 -0
- package/packages/core/src/detectors/ports.ts +99 -0
- package/packages/core/src/detectors/project.ts +50 -0
- package/packages/core/src/detectors/readme.ts +131 -0
- package/packages/core/src/index.ts +31 -0
- package/packages/core/src/reporters/index.ts +3 -0
- package/packages/core/src/reporters/json.ts +5 -0
- package/packages/core/src/reporters/labels.ts +40 -0
- package/packages/core/src/reporters/markdown.ts +63 -0
- package/packages/core/src/reporters/text.ts +108 -0
- package/packages/core/src/run.ts +111 -0
- package/packages/core/src/scoring.ts +60 -0
- package/packages/core/src/types.ts +85 -0
- package/packages/core/src/utils/files.ts +109 -0
- package/packages/core/tests/config.test.ts +53 -0
- package/packages/core/tests/detectors.test.ts +127 -0
- package/packages/core/tests/fixtures/docker-compose-missing-env/.env.example +1 -0
- package/packages/core/tests/fixtures/docker-compose-missing-env/README.md +5 -0
- package/packages/core/tests/fixtures/docker-compose-missing-env/docker-compose.yml +6 -0
- package/packages/core/tests/fixtures/env-missing/.env.example +1 -0
- package/packages/core/tests/fixtures/env-missing/README.md +6 -0
- package/packages/core/tests/fixtures/env-missing/package-lock.json +4 -0
- package/packages/core/tests/fixtures/env-missing/package.json +8 -0
- package/packages/core/tests/fixtures/env-missing/src.py +4 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/.env.example +1 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/README.md +8 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/package.json +8 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/pnpm-lock.yaml +1 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/src.ts +3 -0
- package/packages/core/tests/reporters.test.ts +43 -0
- package/packages/core/tests/run.test.ts +37 -0
- package/packages/core/tests/scoring.test.ts +33 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/npm/LICENSE +21 -0
- package/packages/npm/README.md +17 -0
- package/packages/npm/package.json +54 -0
- package/packages/npm/src/index.ts +2 -0
- package/packages/npm/tsconfig.json +12 -0
- package/pnpm-workspace.yaml +4 -0
- package/prettier.config.js +5 -0
- package/scripts/build-package.mjs +30 -0
- package/tsconfig.base.json +17 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readTextFile } from "../utils/files.js";
|
|
3
|
+
|
|
4
|
+
export interface PortDetection {
|
|
5
|
+
port: number;
|
|
6
|
+
file: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CODE_PORT_PATTERNS = [
|
|
10
|
+
/\b(?:app|server)?\.?listen\(\s*(\d{2,5})\b/g,
|
|
11
|
+
/\blisten\(\s*(\d{2,5})\b/g,
|
|
12
|
+
/\bport\s*[:=]\s*["']?(\d{2,5})\b/gi,
|
|
13
|
+
/\b(?:PORT|VITE_PORT)\s*=\s*(\d{2,5})\b/g,
|
|
14
|
+
/\bPORT\s*\|\|\s*(\d{2,5})\b/g,
|
|
15
|
+
/--port\s+(\d{2,5})\b/g,
|
|
16
|
+
/["']\d{2,5}:(\d{2,5})["']/g
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const README_PORT_PATTERNS = [
|
|
20
|
+
/\blocalhost:(\d{2,5})\b/gi,
|
|
21
|
+
/\b127\.0\.0\.1:(\d{2,5})\b/gi,
|
|
22
|
+
/\b0\.0\.0\.0:(\d{2,5})\b/gi
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const PORT_FILES = new Set([
|
|
26
|
+
"package.json",
|
|
27
|
+
".env.example",
|
|
28
|
+
".env.sample",
|
|
29
|
+
".env.template",
|
|
30
|
+
"example.env",
|
|
31
|
+
"vite.config.ts",
|
|
32
|
+
"vite.config.js",
|
|
33
|
+
"next.config.js",
|
|
34
|
+
"docker-compose.yml",
|
|
35
|
+
"docker-compose.yaml",
|
|
36
|
+
"compose.yml",
|
|
37
|
+
"compose.yaml"
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const PORT_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".ts", ".tsx", ".py", ".go", ".rs"]);
|
|
41
|
+
|
|
42
|
+
function collectPorts(text: string, file: string, patterns: RegExp[]): PortDetection[] {
|
|
43
|
+
const ports: PortDetection[] = [];
|
|
44
|
+
for (const pattern of patterns) {
|
|
45
|
+
pattern.lastIndex = 0;
|
|
46
|
+
let match: RegExpExecArray | null;
|
|
47
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
48
|
+
const raw = match[1];
|
|
49
|
+
if (!raw) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const port = Number.parseInt(raw, 10);
|
|
53
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535) {
|
|
54
|
+
ports.push({ port, file });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return ports;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isPortSourceFile(file: string): boolean {
|
|
63
|
+
return PORT_FILES.has(file) || PORT_EXTENSIONS.has(path.extname(file));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function detectRepoPorts(
|
|
67
|
+
repoPath: string,
|
|
68
|
+
files: string[],
|
|
69
|
+
readmeText?: string
|
|
70
|
+
): Promise<{ codePorts: PortDetection[]; readmePorts: number[] }> {
|
|
71
|
+
const codePorts: PortDetection[] = [];
|
|
72
|
+
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
if (!isPortSourceFile(file)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const text = await readTextFile(repoPath, file);
|
|
79
|
+
if (!text) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
codePorts.push(...collectPorts(text, file, CODE_PORT_PATTERNS));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const readmePorts = readmeText
|
|
87
|
+
? Array.from(new Set(collectPorts(readmeText, "README.md", README_PORT_PATTERNS).map((entry) => entry.port)))
|
|
88
|
+
: [];
|
|
89
|
+
|
|
90
|
+
const uniqueCodePorts = new Map<string, PortDetection>();
|
|
91
|
+
for (const detection of codePorts) {
|
|
92
|
+
uniqueCodePorts.set(`${detection.file}:${detection.port}`, detection);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
codePorts: Array.from(uniqueCodePorts.values()),
|
|
97
|
+
readmePorts
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PackageJson, ProjectType } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export function parsePackageJson(text: string | undefined): PackageJson | undefined {
|
|
4
|
+
if (!text) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const value = JSON.parse(text) as unknown;
|
|
10
|
+
if (typeof value === "object" && value !== null) {
|
|
11
|
+
return value as PackageJson;
|
|
12
|
+
}
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function detectProjectTypes(files: string[]): ProjectType[] {
|
|
21
|
+
const types = new Set<ProjectType>();
|
|
22
|
+
|
|
23
|
+
if (files.includes("package.json")) {
|
|
24
|
+
types.add("node");
|
|
25
|
+
}
|
|
26
|
+
if (files.some((file) => file === "pyproject.toml" || file === "requirements.txt" || file.endsWith(".py"))) {
|
|
27
|
+
types.add("python");
|
|
28
|
+
}
|
|
29
|
+
if (files.includes("go.mod") || files.some((file) => file.endsWith(".go"))) {
|
|
30
|
+
types.add("go");
|
|
31
|
+
}
|
|
32
|
+
if (files.includes("Cargo.toml") || files.some((file) => file.endsWith(".rs"))) {
|
|
33
|
+
types.add("rust");
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
files.some(
|
|
37
|
+
(file) =>
|
|
38
|
+
file === "Dockerfile" ||
|
|
39
|
+
file.endsWith("/Dockerfile") ||
|
|
40
|
+
file === "docker-compose.yml" ||
|
|
41
|
+
file === "docker-compose.yaml" ||
|
|
42
|
+
file === "compose.yml" ||
|
|
43
|
+
file === "compose.yaml"
|
|
44
|
+
)
|
|
45
|
+
) {
|
|
46
|
+
types.add("docker");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return Array.from(types);
|
|
50
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { PackageManager } from "../types.js";
|
|
2
|
+
import { findAnyCaseInsensitive } from "../utils/files.js";
|
|
3
|
+
import { isPackageManager } from "./packageManager.js";
|
|
4
|
+
|
|
5
|
+
export const README_FILENAMES = ["README.md", "readme.md", "Readme.md"];
|
|
6
|
+
|
|
7
|
+
export interface ReadmeCommand {
|
|
8
|
+
command: string;
|
|
9
|
+
packageManager?: PackageManager;
|
|
10
|
+
kind: "install" | "run" | "test" | "build" | "docker" | "other";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SHELL_LANGUAGES = new Set(["", "bash", "sh", "shell", "zsh", "console", "terminal"]);
|
|
14
|
+
|
|
15
|
+
export function findReadmeFile(files: string[]): string | undefined {
|
|
16
|
+
return findAnyCaseInsensitive(files, README_FILENAMES);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeCommand(line: string): string {
|
|
20
|
+
return line
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/^(?:\$|>|❯)\s*/, "")
|
|
23
|
+
.replace(/^sudo\s+/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function extractShellBlocks(markdown: string): string[] {
|
|
27
|
+
const blocks: string[] = [];
|
|
28
|
+
const fenceRegex = /```([A-Za-z0-9_-]*)[^\n]*\n([\s\S]*?)```/g;
|
|
29
|
+
let match: RegExpExecArray | null;
|
|
30
|
+
|
|
31
|
+
while ((match = fenceRegex.exec(markdown)) !== null) {
|
|
32
|
+
const language = (match[1] ?? "").toLowerCase();
|
|
33
|
+
const body = match[2] ?? "";
|
|
34
|
+
if (SHELL_LANGUAGES.has(language)) {
|
|
35
|
+
blocks.push(body);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return blocks;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function detectKind(command: string): ReadmeCommand["kind"] {
|
|
43
|
+
if (/\bdocker(?:\s+compose|-compose)\s+up\b/.test(command)) {
|
|
44
|
+
return "docker";
|
|
45
|
+
}
|
|
46
|
+
if (/\b(?:npm|pnpm|yarn|bun)\s+(?:install|i)\b/.test(command)) {
|
|
47
|
+
return "install";
|
|
48
|
+
}
|
|
49
|
+
if (hasPackageScriptCommand(command, "test")) {
|
|
50
|
+
return "test";
|
|
51
|
+
}
|
|
52
|
+
if (hasPackageScriptCommand(command, "build")) {
|
|
53
|
+
return "build";
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
hasPackageScriptCommand(command, "dev") ||
|
|
57
|
+
hasPackageScriptCommand(command, "start") ||
|
|
58
|
+
hasPackageScriptCommand(command, "serve")
|
|
59
|
+
) {
|
|
60
|
+
return "run";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return "other";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasPackageScriptCommand(command: string, scriptName: string): boolean {
|
|
67
|
+
const escaped = scriptName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
68
|
+
const npmPattern = new RegExp(`\\bnpm\\s+run\\s+${escaped}\\b`);
|
|
69
|
+
const directPattern = new RegExp(
|
|
70
|
+
`\\b(?:pnpm|yarn|bun)\\b(?:\\s+(?:--filter|-F)\\s+\\S+)?\\s+(?:run\\s+)?${escaped}\\b`
|
|
71
|
+
);
|
|
72
|
+
const bunRunPattern = new RegExp(`\\bbun\\s+run\\s+${escaped}\\b`);
|
|
73
|
+
|
|
74
|
+
return npmPattern.test(command) || directPattern.test(command) || bunRunPattern.test(command);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function detectPackageManagerFromCommand(command: string): PackageManager | undefined {
|
|
78
|
+
const match = command.match(/\b(npm|pnpm|yarn|bun)\b/);
|
|
79
|
+
const manager = match?.[1];
|
|
80
|
+
return manager && isPackageManager(manager) ? manager : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function extractReadmeCommands(markdown: string): ReadmeCommand[] {
|
|
84
|
+
const commands: ReadmeCommand[] = [];
|
|
85
|
+
|
|
86
|
+
for (const block of extractShellBlocks(markdown)) {
|
|
87
|
+
for (const rawLine of block.split(/\r?\n/)) {
|
|
88
|
+
const command = normalizeCommand(rawLine);
|
|
89
|
+
if (!command || command.startsWith("#")) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const packageManager = detectPackageManagerFromCommand(command);
|
|
94
|
+
const kind = detectKind(command);
|
|
95
|
+
if (packageManager || kind !== "other") {
|
|
96
|
+
commands.push({ command, packageManager, kind });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return commands;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function readmeMentionsRunCommand(commands: ReadmeCommand[]): boolean {
|
|
105
|
+
return commands.some((command) => command.kind === "run" || command.kind === "docker");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readmeMentionsSetupCommand(commands: ReadmeCommand[]): boolean {
|
|
109
|
+
return commands.some(
|
|
110
|
+
(command) =>
|
|
111
|
+
command.kind === "docker" ||
|
|
112
|
+
(command.kind === "install" && !isPackageInstallCommand(command.command))
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isPackageInstallCommand(command: string): boolean {
|
|
117
|
+
const parts = command.trim().split(/\s+/);
|
|
118
|
+
const managerIndex = parts.findIndex((part) => part === "npm" || part === "pnpm" || part === "yarn" || part === "bun");
|
|
119
|
+
if (managerIndex < 0) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const installIndex = parts.findIndex(
|
|
124
|
+
(part, index) => index > managerIndex && (part === "install" || part === "i")
|
|
125
|
+
);
|
|
126
|
+
if (installIndex < 0) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return parts.slice(installIndex + 1).some((part) => !part.startsWith("-") && !part.includes("="));
|
|
131
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CLONECHECK_VERSION,
|
|
3
|
+
DEFAULT_CONFIG,
|
|
4
|
+
type CheckStatus,
|
|
5
|
+
type ClonecheckCheck,
|
|
6
|
+
type ClonecheckCheckResult,
|
|
7
|
+
type ClonecheckConfig,
|
|
8
|
+
type ClonecheckContext,
|
|
9
|
+
type ClonecheckIssue,
|
|
10
|
+
type ClonecheckReport,
|
|
11
|
+
type PackageJson,
|
|
12
|
+
type PackageManager,
|
|
13
|
+
type ProjectType,
|
|
14
|
+
type RunClonecheckOptions,
|
|
15
|
+
type Severity
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
export { ClonecheckConfigError, initConfig, loadConfig } from "./config.js";
|
|
18
|
+
export { createClonecheckContext, runClonecheck } from "./run.js";
|
|
19
|
+
export { getChecks } from "./checks/index.js";
|
|
20
|
+
export { detectPackageManager } from "./detectors/packageManager.js";
|
|
21
|
+
export {
|
|
22
|
+
detectEnvVarUsages,
|
|
23
|
+
extractEnvVarUsages,
|
|
24
|
+
findMissingEnvVars,
|
|
25
|
+
generateEnvExample,
|
|
26
|
+
parseEnvExampleVars
|
|
27
|
+
} from "./detectors/env.js";
|
|
28
|
+
export { extractReadmeCommands, extractShellBlocks, isPackageInstallCommand } from "./detectors/readme.js";
|
|
29
|
+
export { detectDockerComposeEnvVars, extractDockerComposeEnvVars } from "./detectors/dockerCompose.js";
|
|
30
|
+
export { collectSuggestions, scoreChecks, scoreImpactForIssues, statusFromScore } from "./scoring.js";
|
|
31
|
+
export { formatJsonReport, formatMarkdownReport, formatTextReport } from "./reporters/index.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CheckStatus, ClonecheckReport } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export function statusSymbol(status: CheckStatus): string {
|
|
4
|
+
switch (status) {
|
|
5
|
+
case "pass":
|
|
6
|
+
return "✅";
|
|
7
|
+
case "warning":
|
|
8
|
+
return "⚠️";
|
|
9
|
+
case "fail":
|
|
10
|
+
return "❌";
|
|
11
|
+
case "skip":
|
|
12
|
+
return "⏭️";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function checkStatusLabel(status: CheckStatus): string {
|
|
17
|
+
switch (status) {
|
|
18
|
+
case "pass":
|
|
19
|
+
return "Pass";
|
|
20
|
+
case "warning":
|
|
21
|
+
return "Warning";
|
|
22
|
+
case "fail":
|
|
23
|
+
return "Fail";
|
|
24
|
+
case "skip":
|
|
25
|
+
return "Skip";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function reportStatusLabel(status: ClonecheckReport["status"]): string {
|
|
30
|
+
switch (status) {
|
|
31
|
+
case "excellent":
|
|
32
|
+
return "Excellent";
|
|
33
|
+
case "good":
|
|
34
|
+
return "Good";
|
|
35
|
+
case "needs-work":
|
|
36
|
+
return "Needs work";
|
|
37
|
+
case "poor":
|
|
38
|
+
return "Poor";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ClonecheckIssue, ClonecheckReport } from "../types.js";
|
|
2
|
+
import { checkStatusLabel, reportStatusLabel, statusSymbol } from "./labels.js";
|
|
3
|
+
|
|
4
|
+
function escapeTableCell(value: string): string {
|
|
5
|
+
return value.replace(/\|/g, "\\|");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function formatIssue(issue: ClonecheckIssue): string {
|
|
9
|
+
const location = issue.file ? ` (${issue.file}${issue.line ? `:${issue.line}` : ""})` : "";
|
|
10
|
+
return `- **${issue.title}**${location}: ${issue.message}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatMarkdownReport(report: ClonecheckReport): string {
|
|
14
|
+
const lines: string[] = [
|
|
15
|
+
"# Clonecheck Report",
|
|
16
|
+
"",
|
|
17
|
+
`Score: ${report.score}/100 `,
|
|
18
|
+
`Status: ${reportStatusLabel(report.status)}`,
|
|
19
|
+
"",
|
|
20
|
+
"## Summary",
|
|
21
|
+
"",
|
|
22
|
+
"| Check | Status | Issues |",
|
|
23
|
+
"|---|---:|---:|"
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
for (const check of report.checks) {
|
|
27
|
+
lines.push(
|
|
28
|
+
`| ${escapeTableCell(check.id)} | ${statusSymbol(check.status)} ${checkStatusLabel(check.status)} | ${check.issues.length} |`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const checksWithIssues = report.checks.filter((check) => check.issues.length > 0);
|
|
33
|
+
if (checksWithIssues.length > 0) {
|
|
34
|
+
lines.push("", "## Issues", "");
|
|
35
|
+
for (const check of checksWithIssues) {
|
|
36
|
+
lines.push(`### ${check.id}`, "");
|
|
37
|
+
for (const issue of check.issues) {
|
|
38
|
+
lines.push(formatIssue(issue));
|
|
39
|
+
}
|
|
40
|
+
lines.push("");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (report.suggestions.length > 0) {
|
|
45
|
+
lines.push("## Suggestions", "");
|
|
46
|
+
report.suggestions.forEach((suggestion, index) => {
|
|
47
|
+
lines.push(`${index + 1}. ${suggestion}`);
|
|
48
|
+
});
|
|
49
|
+
lines.push("");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
lines.push(
|
|
53
|
+
"## Metadata",
|
|
54
|
+
"",
|
|
55
|
+
`- Repository: \`${report.projectName}\``,
|
|
56
|
+
`- Path: \`${report.repoPath}\``,
|
|
57
|
+
`- Project types: ${report.metadata.detectedProjectTypes.join(", ") || "unknown"}`,
|
|
58
|
+
`- Package manager: ${report.metadata.detectedPackageManager ?? "unknown"}`,
|
|
59
|
+
`- Generated at: ${report.metadata.generatedAt}`
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import type { CheckStatus, ClonecheckCheckResult, ClonecheckReport } from "../types.js";
|
|
3
|
+
import { reportStatusLabel, statusSymbol } from "./labels.js";
|
|
4
|
+
|
|
5
|
+
export interface TextReporterOptions {
|
|
6
|
+
color?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function colorizeStatus(status: CheckStatus, text: string, useColor: boolean): string {
|
|
10
|
+
if (!useColor) {
|
|
11
|
+
return text;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
switch (status) {
|
|
15
|
+
case "pass":
|
|
16
|
+
return pc.green(text);
|
|
17
|
+
case "warning":
|
|
18
|
+
return pc.yellow(text);
|
|
19
|
+
case "fail":
|
|
20
|
+
return pc.red(text);
|
|
21
|
+
case "skip":
|
|
22
|
+
return pc.dim(text);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function defaultPassMessage(check: ClonecheckCheckResult, report: ClonecheckReport): string {
|
|
27
|
+
if (check.status === "skip") {
|
|
28
|
+
return "not applicable for this repository";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
switch (check.id) {
|
|
32
|
+
case "package-manager-consistency":
|
|
33
|
+
return report.metadata.detectedPackageManager
|
|
34
|
+
? `package manager is consistent: ${report.metadata.detectedPackageManager}`
|
|
35
|
+
: "no package manager mismatch found";
|
|
36
|
+
case "scripts-availability":
|
|
37
|
+
return "package scripts include an obvious local workflow";
|
|
38
|
+
case "env-example":
|
|
39
|
+
return "environment variables used in code are documented";
|
|
40
|
+
case "readme-commands":
|
|
41
|
+
return "README setup commands look consistent";
|
|
42
|
+
case "port-documentation":
|
|
43
|
+
return "README port documentation looks consistent";
|
|
44
|
+
case "docker-compose-env":
|
|
45
|
+
return "Docker Compose environment variables are documented";
|
|
46
|
+
case "project-files":
|
|
47
|
+
return "core project files are present";
|
|
48
|
+
case "ci-presence":
|
|
49
|
+
return "found at least one GitHub Actions workflow";
|
|
50
|
+
default:
|
|
51
|
+
return "no issues found";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatIssueLocation(file?: string, line?: number): string {
|
|
56
|
+
if (!file) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return line ? ` (${file}:${line})` : ` (${file})`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatCheck(check: ClonecheckCheckResult, report: ClonecheckReport, useColor: boolean): string[] {
|
|
64
|
+
const symbol = statusSymbol(check.status);
|
|
65
|
+
const label = colorizeStatus(check.status, check.id, useColor);
|
|
66
|
+
const lines = [` ${symbol} ${label}`];
|
|
67
|
+
|
|
68
|
+
if (check.issues.length === 0) {
|
|
69
|
+
lines.push(` ${defaultPassMessage(check, report)}`);
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const issue of check.issues) {
|
|
74
|
+
const location = formatIssueLocation(issue.file, issue.line);
|
|
75
|
+
lines.push(` ${issue.message}${location}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatTextReport(report: ClonecheckReport, options: TextReporterOptions = {}): string {
|
|
82
|
+
const useColor = options.color ?? Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
|
|
83
|
+
const lines = [
|
|
84
|
+
`clonecheck v${report.metadata.clonecheckVersion}`,
|
|
85
|
+
"",
|
|
86
|
+
`Repository: ${report.projectName}`,
|
|
87
|
+
`Path: ${report.repoPath}`,
|
|
88
|
+
"",
|
|
89
|
+
`Score: ${report.score}/100`,
|
|
90
|
+
`Status: ${reportStatusLabel(report.status)}`,
|
|
91
|
+
"",
|
|
92
|
+
"Checks:"
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
for (const check of report.checks) {
|
|
96
|
+
lines.push(...formatCheck(check, report, useColor), "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (report.suggestions.length > 0) {
|
|
100
|
+
lines.push("Suggestions:");
|
|
101
|
+
report.suggestions.forEach((suggestion, index) => {
|
|
102
|
+
lines.push(` ${index + 1}. ${suggestion}`);
|
|
103
|
+
});
|
|
104
|
+
lines.push("");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
108
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getChecks } from "./checks/index.js";
|
|
4
|
+
import { createResult } from "./checks/helpers.js";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { loadEnvExampleFiles } from "./detectors/env.js";
|
|
7
|
+
import { detectPackageManager } from "./detectors/packageManager.js";
|
|
8
|
+
import { detectProjectTypes, parsePackageJson } from "./detectors/project.js";
|
|
9
|
+
import { findReadmeFile } from "./detectors/readme.js";
|
|
10
|
+
import { collectSuggestions, scoreChecks, statusFromScore } from "./scoring.js";
|
|
11
|
+
import {
|
|
12
|
+
CLONECHECK_VERSION,
|
|
13
|
+
type ClonecheckContext,
|
|
14
|
+
type ClonecheckReport,
|
|
15
|
+
type RunClonecheckOptions
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
import { discoverFiles, readTextFile } from "./utils/files.js";
|
|
18
|
+
|
|
19
|
+
async function assertDirectory(repoPath: string): Promise<void> {
|
|
20
|
+
try {
|
|
21
|
+
const info = await stat(repoPath);
|
|
22
|
+
if (!info.isDirectory()) {
|
|
23
|
+
throw new Error(`${repoPath} is not a directory.`);
|
|
24
|
+
}
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof Error && error.message.endsWith("is not a directory.")) {
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Repository path does not exist or cannot be read: ${repoPath}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function projectNameFromPackageJson(packageJson: unknown): string | undefined {
|
|
34
|
+
if (!packageJson || typeof packageJson !== "object") {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const name = (packageJson as { name?: unknown }).name;
|
|
39
|
+
return typeof name === "string" && name.length > 0 ? name : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createClonecheckContext(options: RunClonecheckOptions): Promise<{
|
|
43
|
+
context: ClonecheckContext;
|
|
44
|
+
projectName: string;
|
|
45
|
+
}> {
|
|
46
|
+
const repoPath = path.resolve(options.repoPath);
|
|
47
|
+
await assertDirectory(repoPath);
|
|
48
|
+
|
|
49
|
+
const { config } = await loadConfig(repoPath, options.configPath);
|
|
50
|
+
const files = await discoverFiles(repoPath, config.ignore);
|
|
51
|
+
const packageJson = parsePackageJson(await readTextFile(repoPath, "package.json"));
|
|
52
|
+
const readmeFile = findReadmeFile(files);
|
|
53
|
+
const readmeText = readmeFile ? await readTextFile(repoPath, readmeFile) : undefined;
|
|
54
|
+
const envExamples = await loadEnvExampleFiles(repoPath, files);
|
|
55
|
+
const detectedProjectTypes = detectProjectTypes(files);
|
|
56
|
+
const packageManager = detectPackageManager(files, packageJson);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
projectName: projectNameFromPackageJson(packageJson) ?? path.basename(repoPath),
|
|
60
|
+
context: {
|
|
61
|
+
repoPath,
|
|
62
|
+
files,
|
|
63
|
+
config,
|
|
64
|
+
packageJson,
|
|
65
|
+
readmeText,
|
|
66
|
+
envExampleText: envExamples.text,
|
|
67
|
+
envExampleVars: envExamples.vars,
|
|
68
|
+
detectedProjectTypes,
|
|
69
|
+
detectedPackageManager: packageManager.manager,
|
|
70
|
+
strict: Boolean(options.strict)
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runClonecheck(options: RunClonecheckOptions): Promise<ClonecheckReport> {
|
|
76
|
+
const { context, projectName } = await createClonecheckContext(options);
|
|
77
|
+
const rawResults = [];
|
|
78
|
+
|
|
79
|
+
for (const check of getChecks()) {
|
|
80
|
+
if (context.config.checks[check.id] === false) {
|
|
81
|
+
rawResults.push(
|
|
82
|
+
createResult({
|
|
83
|
+
id: check.id,
|
|
84
|
+
title: check.title,
|
|
85
|
+
status: "skip"
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
rawResults.push(await check.run(context));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const scored = scoreChecks(rawResults, context.strict);
|
|
95
|
+
const suggestions = collectSuggestions(scored.checks);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
repoPath: context.repoPath,
|
|
99
|
+
projectName,
|
|
100
|
+
score: scored.score,
|
|
101
|
+
status: statusFromScore(scored.score),
|
|
102
|
+
checks: scored.checks,
|
|
103
|
+
suggestions,
|
|
104
|
+
metadata: {
|
|
105
|
+
detectedProjectTypes: context.detectedProjectTypes,
|
|
106
|
+
detectedPackageManager: context.detectedPackageManager,
|
|
107
|
+
generatedAt: new Date().toISOString(),
|
|
108
|
+
clonecheckVersion: CLONECHECK_VERSION
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|