shipready 1.5.3 → 1.7.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 +24 -0
- package/dist/checks/env.js +11 -2
- package/dist/checks/gitignore.js +45 -4
- package/dist/checks/packageJson.js +46 -1
- package/dist/fixers/gitignore.js +4 -9
- package/dist/scanner.js +38 -19
- package/dist/utils/framework.js +119 -1
- package/dist/utils/report.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -242,6 +242,30 @@ console.log("startup banner"); // shipready-ignore
|
|
|
242
242
|
|
|
243
243
|
For project-wide exceptions, prefer `secretAllowlist` in the config (see below) so the suppression is reviewable in one place.
|
|
244
244
|
|
|
245
|
+
## Scoring
|
|
246
|
+
|
|
247
|
+
The score starts at 100 and every deduction is itemized right under the score bar - the number is never a black box:
|
|
248
|
+
|
|
249
|
+
| Deduction | Points |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
| No package.json | -20 |
|
|
252
|
+
| No README | -15 |
|
|
253
|
+
| Weak README | -8 |
|
|
254
|
+
| No build script | -8 |
|
|
255
|
+
| No test script | -6 |
|
|
256
|
+
| `.env.example` missing | -10 |
|
|
257
|
+
| `.env` not ignored by git | -15 |
|
|
258
|
+
| Hardcoded secret | -25 each (capped at -50) |
|
|
259
|
+
| Secret in git history | -10 each (capped at -30) |
|
|
260
|
+
| TODO/FIXME comment | -2 each (capped at -15) |
|
|
261
|
+
|
|
262
|
+
Checks adapt to what your repo actually is:
|
|
263
|
+
|
|
264
|
+
- **Monorepos** - workspaces (`package.json` `workspaces`, `pnpm-workspace.yaml`, including `**` globs) and conventional `frontend/` + `backend/` splits are detected; scripts and `.env.example` files in workspace packages count.
|
|
265
|
+
- **Libraries** - packages with a `files` allowlist aren't required to have `dev`/`build` scripts.
|
|
266
|
+
- **Non-JS ecosystems** - Go/Rust/Python/etc. projects get ecosystem-appropriate `.gitignore` expectations, and `.env` is only expected if the code actually reads env vars.
|
|
267
|
+
- **Mixed-language repos** - a FastAPI + React project reads "Vite + Python", not just "Vite".
|
|
268
|
+
|
|
245
269
|
## Configuration
|
|
246
270
|
|
|
247
271
|
Optional `shipready.config.json` in your project root:
|
package/dist/checks/env.js
CHANGED
|
@@ -83,7 +83,7 @@ export function findRealLookingExampleValues(content) {
|
|
|
83
83
|
return suspicious;
|
|
84
84
|
}
|
|
85
85
|
/** Checks .env / .env.example / gitignore safety around env vars. */
|
|
86
|
-
export function checkEnv(root, envUsages) {
|
|
86
|
+
export function checkEnv(root, envUsages, workspaceDirs = []) {
|
|
87
87
|
const findings = [];
|
|
88
88
|
const hasEnv = fileExists(root, ".env");
|
|
89
89
|
const exampleContent = readTextFile(root, ".env.example") ?? readTextFile(root, ".env.sample");
|
|
@@ -111,8 +111,17 @@ export function checkEnv(root, envUsages) {
|
|
|
111
111
|
});
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
+
// Monorepos keep .env.example per workspace package, not at the root.
|
|
115
|
+
const workspaceExamples = workspaceDirs.filter((d) => fileExists(root, `${d}/.env.example`) || fileExists(root, `${d}/.env.sample`));
|
|
114
116
|
// .env.example presence
|
|
115
|
-
if (usedVars.length > 0 && !hasExample) {
|
|
117
|
+
if (usedVars.length > 0 && !hasExample && workspaceExamples.length > 0) {
|
|
118
|
+
findings.push({
|
|
119
|
+
severity: "success",
|
|
120
|
+
rule: "env.example-found-workspaces",
|
|
121
|
+
message: `.env.example found in ${workspaceExamples.length} workspace package${workspaceExamples.length > 1 ? "s" : ""}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else if (usedVars.length > 0 && !hasExample) {
|
|
116
125
|
findings.push({
|
|
117
126
|
severity: "error",
|
|
118
127
|
rule: "env.example-missing",
|
package/dist/checks/gitignore.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { isNodeEcosystem } from "../utils/framework.js";
|
|
1
2
|
import { readTextFile } from "../utils/files.js";
|
|
3
|
+
/** Node-ecosystem entries (also the default when the framework is unknown). */
|
|
2
4
|
export const IMPORTANT_IGNORES = [
|
|
3
5
|
".env",
|
|
4
6
|
".env.local",
|
|
@@ -7,8 +9,47 @@ export const IMPORTANT_IGNORES = [
|
|
|
7
9
|
"build",
|
|
8
10
|
".next",
|
|
9
11
|
];
|
|
12
|
+
/**
|
|
13
|
+
* Expected entries per ecosystem. Suggesting node_modules to a Go project
|
|
14
|
+
* (or target/ to a Python one) is noise that erodes trust in the tool, so
|
|
15
|
+
* each ecosystem only gets entries that actually apply to it.
|
|
16
|
+
*/
|
|
17
|
+
const ECOSYSTEM_IGNORES = {
|
|
18
|
+
Python: [".env", "__pycache__", ".venv"],
|
|
19
|
+
Go: [".env"],
|
|
20
|
+
Rust: [".env", "target"],
|
|
21
|
+
PHP: [".env", "vendor"],
|
|
22
|
+
Ruby: [".env", ".bundle"],
|
|
23
|
+
Java: [".env", "target", "build"],
|
|
24
|
+
Deno: [".env"],
|
|
25
|
+
"Static HTML": [".env"],
|
|
26
|
+
unknown: [".env"],
|
|
27
|
+
};
|
|
28
|
+
/** Returns the entries a project of the given framework should ignore. */
|
|
29
|
+
export function importantIgnoresFor(framework, ctx) {
|
|
30
|
+
// Without context, keep the legacy full list (also the config-less API).
|
|
31
|
+
if (!framework && !ctx)
|
|
32
|
+
return IMPORTANT_IGNORES;
|
|
33
|
+
if (!framework || isNodeEcosystem(framework)) {
|
|
34
|
+
const entries = [".env", ".env.local", "node_modules"];
|
|
35
|
+
// Build output dirs are only worth ignoring when something builds.
|
|
36
|
+
if (ctx?.hasBuildScript !== false) {
|
|
37
|
+
if (framework === "Next.js")
|
|
38
|
+
entries.push(".next");
|
|
39
|
+
else
|
|
40
|
+
entries.push("dist");
|
|
41
|
+
}
|
|
42
|
+
return entries;
|
|
43
|
+
}
|
|
44
|
+
const base = ECOSYSTEM_IGNORES[framework] ?? [".env"];
|
|
45
|
+
// A repo that reads no env vars gains nothing from ignoring .env -
|
|
46
|
+
// suggesting it to e.g. ripgrep is pure noise.
|
|
47
|
+
if (ctx?.usesEnv === false)
|
|
48
|
+
return base.filter((e) => e !== ".env");
|
|
49
|
+
return base;
|
|
50
|
+
}
|
|
10
51
|
/** Returns which important entries are missing from gitignore content. */
|
|
11
|
-
export function missingIgnoreEntries(content) {
|
|
52
|
+
export function missingIgnoreEntries(content, framework, ctx) {
|
|
12
53
|
const lines = content
|
|
13
54
|
.split("\n")
|
|
14
55
|
.map((l) => l.trim())
|
|
@@ -23,10 +64,10 @@ export function missingIgnoreEntries(content) {
|
|
|
23
64
|
// "node_modules/" style already normalized above
|
|
24
65
|
return false;
|
|
25
66
|
});
|
|
26
|
-
return
|
|
67
|
+
return importantIgnoresFor(framework, ctx).filter((e) => !covers(e));
|
|
27
68
|
}
|
|
28
69
|
/** Checks .gitignore existence and important entries. */
|
|
29
|
-
export function checkGitignore(root) {
|
|
70
|
+
export function checkGitignore(root, framework, ctx) {
|
|
30
71
|
const findings = [];
|
|
31
72
|
const content = readTextFile(root, ".gitignore");
|
|
32
73
|
if (content === null) {
|
|
@@ -37,7 +78,7 @@ export function checkGitignore(root) {
|
|
|
37
78
|
});
|
|
38
79
|
return { name: "gitignore", findings };
|
|
39
80
|
}
|
|
40
|
-
const missing = missingIgnoreEntries(content);
|
|
81
|
+
const missing = missingIgnoreEntries(content, framework, ctx);
|
|
41
82
|
if (missing.length > 0) {
|
|
42
83
|
findings.push({
|
|
43
84
|
severity: "warning",
|
|
@@ -1,5 +1,23 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileExists } from "../utils/files.js";
|
|
1
4
|
import { isNodeEcosystem } from "../utils/framework.js";
|
|
2
5
|
const IMPORTANT_SCRIPTS = ["dev", "build", "test", "lint"];
|
|
6
|
+
/** Linter configs that count as lint coverage even without a lint script. */
|
|
7
|
+
const LINTER_CONFIGS = [
|
|
8
|
+
"biome.json",
|
|
9
|
+
"biome.jsonc",
|
|
10
|
+
"eslint.config.js",
|
|
11
|
+
"eslint.config.mjs",
|
|
12
|
+
"eslint.config.cjs",
|
|
13
|
+
"eslint.config.ts",
|
|
14
|
+
".eslintrc",
|
|
15
|
+
".eslintrc.js",
|
|
16
|
+
".eslintrc.cjs",
|
|
17
|
+
".eslintrc.json",
|
|
18
|
+
".eslintrc.yml",
|
|
19
|
+
".eslintrc.yaml",
|
|
20
|
+
];
|
|
3
21
|
/** Checks package.json existence and important scripts. */
|
|
4
22
|
export function checkPackageJson(project) {
|
|
5
23
|
const findings = [];
|
|
@@ -26,7 +44,34 @@ export function checkPackageJson(project) {
|
|
|
26
44
|
rule: "package-json.found",
|
|
27
45
|
message: "package.json found",
|
|
28
46
|
});
|
|
29
|
-
const
|
|
47
|
+
const hasLinterConfig = LINTER_CONFIGS.some((f) => fileExists(project.root, f));
|
|
48
|
+
// Published libraries (a "files" allowlist, not private) ship source, not
|
|
49
|
+
// a running app - requiring dev/build scripts of e.g. express is noise.
|
|
50
|
+
const pkg = project.packageJson;
|
|
51
|
+
const isLibrary = Array.isArray(pkg?.["files"]) && pkg?.["private"] !== true;
|
|
52
|
+
// In monorepos the scripts live in workspace packages, not the root.
|
|
53
|
+
const workspaceScripts = new Set();
|
|
54
|
+
for (const dir of project.workspaceDirs ?? []) {
|
|
55
|
+
try {
|
|
56
|
+
const wsPkg = JSON.parse(fs.readFileSync(path.join(project.root, dir, "package.json"), "utf8"));
|
|
57
|
+
for (const name of Object.keys(wsPkg.scripts ?? {}))
|
|
58
|
+
workspaceScripts.add(name);
|
|
59
|
+
}
|
|
60
|
+
catch { /* skip unreadable workspace */ }
|
|
61
|
+
}
|
|
62
|
+
const missing = IMPORTANT_SCRIPTS.filter((s) => {
|
|
63
|
+
if (project.scripts[s])
|
|
64
|
+
return false;
|
|
65
|
+
if (workspaceScripts.has(s))
|
|
66
|
+
return false;
|
|
67
|
+
if (isLibrary && (s === "dev" || s === "build"))
|
|
68
|
+
return false;
|
|
69
|
+
// A biome/eslint config means linting is set up; the script name is
|
|
70
|
+
// a convention, not a requirement (e.g. Biome repos run "biome check").
|
|
71
|
+
if (s === "lint" && hasLinterConfig)
|
|
72
|
+
return false;
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
30
75
|
if (missing.length > 0) {
|
|
31
76
|
findings.push({
|
|
32
77
|
severity: "warning",
|
package/dist/fixers/gitignore.js
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import { missingIgnoreEntries } from "../checks/gitignore.js";
|
|
1
|
+
import { importantIgnoresFor, missingIgnoreEntries } from "../checks/gitignore.js";
|
|
2
2
|
import { readTextFile, writeTextFile } from "../utils/files.js";
|
|
3
3
|
/** Adds missing important entries to .gitignore (creates it if absent). */
|
|
4
|
-
export function fixGitignore(root, dryRun = false) {
|
|
4
|
+
export function fixGitignore(root, dryRun = false, framework) {
|
|
5
5
|
const file = ".gitignore";
|
|
6
6
|
const existing = readTextFile(root, file);
|
|
7
7
|
if (existing === null) {
|
|
8
8
|
const content = [
|
|
9
9
|
"# Added by shipready",
|
|
10
|
-
|
|
11
|
-
".env.local",
|
|
12
|
-
"node_modules",
|
|
13
|
-
"dist",
|
|
14
|
-
"build",
|
|
15
|
-
".next",
|
|
10
|
+
...importantIgnoresFor(framework),
|
|
16
11
|
"",
|
|
17
12
|
].join("\n");
|
|
18
13
|
if (!dryRun) {
|
|
@@ -20,7 +15,7 @@ export function fixGitignore(root, dryRun = false) {
|
|
|
20
15
|
}
|
|
21
16
|
return { file, action: "created", dryRun, preview: content };
|
|
22
17
|
}
|
|
23
|
-
const missing = missingIgnoreEntries(existing);
|
|
18
|
+
const missing = missingIgnoreEntries(existing, framework);
|
|
24
19
|
if (missing.length === 0) {
|
|
25
20
|
return { file, action: "skipped", reason: "already complete" };
|
|
26
21
|
}
|
package/dist/scanner.js
CHANGED
|
@@ -8,7 +8,7 @@ import { checkHistory, isGitRepo, scanGitHistory } from "./checks/history.js";
|
|
|
8
8
|
import { verifySecrets } from "./checks/verify.js";
|
|
9
9
|
import { checkTodos, scanContentForTodos } from "./checks/todos.js";
|
|
10
10
|
import { fileExists, findSourceFiles, isProbablyBinary, readJsonFile, readTextFile, } from "./utils/files.js";
|
|
11
|
-
import { detectFramework, detectPackageManager } from "./utils/framework.js";
|
|
11
|
+
import { detectExtraLanguages, detectFramework, detectPackageManager, findWorkspaceDirs } from "./utils/framework.js";
|
|
12
12
|
import { isRuleDisabled, loadConfig } from "./config.js";
|
|
13
13
|
/** File extensions considered code for content scanning (not config/data). */
|
|
14
14
|
const CODE_ONLY = new Set([
|
|
@@ -21,14 +21,17 @@ const CODE_ONLY = new Set([
|
|
|
21
21
|
export async function detectProject(root, config) {
|
|
22
22
|
const pkg = readJsonFile(root, "package.json");
|
|
23
23
|
const sourceFiles = await findSourceFiles(root, config?.ignore ?? []);
|
|
24
|
+
const workspaceDirs = findWorkspaceDirs(root, pkg);
|
|
24
25
|
return {
|
|
25
26
|
root,
|
|
26
27
|
hasPackageJson: fileExists(root, "package.json"),
|
|
27
28
|
packageJson: pkg,
|
|
28
29
|
packageManager: detectPackageManager(root),
|
|
29
|
-
framework: detectFramework(pkg, root, sourceFiles),
|
|
30
|
+
framework: detectFramework(pkg, root, sourceFiles, workspaceDirs),
|
|
30
31
|
scripts: pkg?.scripts ?? {},
|
|
31
32
|
sourceFiles,
|
|
33
|
+
workspaceDirs,
|
|
34
|
+
extraLanguages: detectExtraLanguages(root, detectFramework(pkg, root, sourceFiles, workspaceDirs)),
|
|
32
35
|
};
|
|
33
36
|
}
|
|
34
37
|
/** Extracts env usages, secrets, and todos from all source files. */
|
|
@@ -73,37 +76,50 @@ export function scanFiles(root, files, secretAllowlist = []) {
|
|
|
73
76
|
}
|
|
74
77
|
return { envUsages, secrets, todos };
|
|
75
78
|
}
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Score deductions per the shipready scoring model. Returns the breakdown
|
|
81
|
+
* so users can see exactly where every point went instead of trusting an
|
|
82
|
+
* opaque number.
|
|
83
|
+
*/
|
|
84
|
+
export function calculateScoreBreakdown(results) {
|
|
79
85
|
const all = results.flatMap((r) => r.findings);
|
|
80
86
|
const has = (rule) => all.some((f) => f.rule === rule);
|
|
81
87
|
const count = (rule) => all.filter((f) => f.rule === rule).length;
|
|
88
|
+
const deductions = [];
|
|
89
|
+
const deduct = (points, reason) => {
|
|
90
|
+
if (points > 0)
|
|
91
|
+
deductions.push({ reason, points });
|
|
92
|
+
};
|
|
82
93
|
if (has("package-json.missing"))
|
|
83
|
-
|
|
94
|
+
deduct(20, "no package.json");
|
|
84
95
|
if (has("readme.missing"))
|
|
85
|
-
|
|
96
|
+
deduct(15, "no README");
|
|
86
97
|
if (has("readme.weak"))
|
|
87
|
-
|
|
98
|
+
deduct(8, "weak README");
|
|
88
99
|
// Missing scripts: look at message to determine which ones
|
|
89
100
|
const scriptsFinding = all.find((f) => f.rule === "scripts.missing");
|
|
90
101
|
if (scriptsFinding) {
|
|
91
102
|
if (scriptsFinding.message.includes("build"))
|
|
92
|
-
|
|
103
|
+
deduct(8, "no build script");
|
|
93
104
|
if (scriptsFinding.message.includes("test"))
|
|
94
|
-
|
|
105
|
+
deduct(6, "no test script");
|
|
95
106
|
}
|
|
96
107
|
if (has("env.example-missing"))
|
|
97
|
-
|
|
108
|
+
deduct(10, ".env.example missing");
|
|
98
109
|
if (has("env.not-ignored"))
|
|
99
|
-
|
|
110
|
+
deduct(15, ".env not ignored");
|
|
100
111
|
const secretCount = count("secrets.detected-item");
|
|
101
|
-
|
|
112
|
+
deduct(Math.min(secretCount * 25, 50), secretCount === 1 ? "hardcoded secret" : `hardcoded secrets (${secretCount})`);
|
|
102
113
|
const historyCount = count("history.secret-item");
|
|
103
|
-
|
|
114
|
+
deduct(Math.min(historyCount * 10, 30), historyCount === 1 ? "secret in git history" : `secrets in git history (${historyCount})`);
|
|
104
115
|
const todoCount = count("todos.item");
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
deduct(Math.min(todoCount * 2, 15), todoCount === 1 ? "TODO comment" : `TODO comments (${todoCount})`);
|
|
117
|
+
const total = deductions.reduce((sum, d) => sum + d.points, 0);
|
|
118
|
+
return { score: Math.max(0, 100 - total), deductions };
|
|
119
|
+
}
|
|
120
|
+
/** Score per the shipready scoring model (see calculateScoreBreakdown). */
|
|
121
|
+
export function calculateScore(results) {
|
|
122
|
+
return calculateScoreBreakdown(results).score;
|
|
107
123
|
}
|
|
108
124
|
/** Removes findings whose rules are disabled by config. */
|
|
109
125
|
function applyDisabledRules(results, disableRules) {
|
|
@@ -133,17 +149,20 @@ export async function runScan(root, options = {}) {
|
|
|
133
149
|
const rawResults = [
|
|
134
150
|
checkPackageJson(project),
|
|
135
151
|
checkReadme(root),
|
|
136
|
-
checkEnv(root, envUsages),
|
|
152
|
+
checkEnv(root, envUsages, project.workspaceDirs),
|
|
137
153
|
checkSecrets(secrets),
|
|
138
154
|
...(options.history ? [checkHistory(historySecrets, historyScanned)] : []),
|
|
139
155
|
checkTodos(todos),
|
|
140
|
-
checkGitignore(root
|
|
156
|
+
checkGitignore(root, project.framework, {
|
|
157
|
+
hasBuildScript: Boolean(project.scripts["build"]),
|
|
158
|
+
usesEnv: envUsages.length > 0,
|
|
159
|
+
}),
|
|
141
160
|
];
|
|
142
161
|
// Disabled rules are removed before scoring so they don't affect the score.
|
|
143
162
|
const results = applyDisabledRules(rawResults, config.disableRules);
|
|
144
163
|
return {
|
|
145
164
|
project,
|
|
146
165
|
results,
|
|
147
|
-
|
|
166
|
+
...calculateScoreBreakdown(results),
|
|
148
167
|
};
|
|
149
168
|
}
|
package/dist/utils/framework.js
CHANGED
|
@@ -1,4 +1,108 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { fileExists } from "./files.js";
|
|
4
|
+
/**
|
|
5
|
+
* Finds workspace package directories for monorepos. Reads patterns from
|
|
6
|
+
* package.json "workspaces" and pnpm-workspace.yaml, expanding one level of
|
|
7
|
+
* trailing-star globs ("apps/*"). Only dirs that contain a package.json
|
|
8
|
+
* count - that's what makes them workspace packages.
|
|
9
|
+
*/
|
|
10
|
+
export function findWorkspaceDirs(root, pkg) {
|
|
11
|
+
const patterns = [];
|
|
12
|
+
const ws = pkg?.["workspaces"];
|
|
13
|
+
if (Array.isArray(ws))
|
|
14
|
+
patterns.push(...ws);
|
|
15
|
+
else if (ws && typeof ws === "object" && Array.isArray(ws.packages)) {
|
|
16
|
+
patterns.push(...(ws.packages));
|
|
17
|
+
}
|
|
18
|
+
const pnpmWs = path.join(root, "pnpm-workspace.yaml");
|
|
19
|
+
if (fs.existsSync(pnpmWs)) {
|
|
20
|
+
try {
|
|
21
|
+
// Minimal YAML: only the common "packages:" list form is supported.
|
|
22
|
+
const lines = fs.readFileSync(pnpmWs, "utf8").split("\n");
|
|
23
|
+
let inPackages = false;
|
|
24
|
+
for (const raw of lines) {
|
|
25
|
+
const line = raw.trim();
|
|
26
|
+
if (line.startsWith("packages:")) {
|
|
27
|
+
inPackages = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (inPackages) {
|
|
31
|
+
if (line.startsWith("- "))
|
|
32
|
+
patterns.push(line.slice(2).trim().replace(/^["']|["']$/g, ""));
|
|
33
|
+
else if (line && !line.startsWith("#"))
|
|
34
|
+
inPackages = false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { /* unreadable yaml: treat as no workspaces */ }
|
|
39
|
+
}
|
|
40
|
+
const dirs = new Set();
|
|
41
|
+
const addPackagesUnder = (base, depth) => {
|
|
42
|
+
const abs = path.join(root, base);
|
|
43
|
+
if (!fs.existsSync(abs))
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
47
|
+
if (!entry.isDirectory() || entry.name === "node_modules")
|
|
48
|
+
continue;
|
|
49
|
+
const rel = path.join(base, entry.name);
|
|
50
|
+
if (fs.existsSync(path.join(root, rel, "package.json")))
|
|
51
|
+
dirs.add(rel);
|
|
52
|
+
// "packages/**/*" style patterns nest one extra level (e.g. astro's
|
|
53
|
+
// packages/integrations/react). Depth-capped to avoid crawling.
|
|
54
|
+
else if (depth > 1)
|
|
55
|
+
addPackagesUnder(rel, depth - 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch { /* unreadable dir */ }
|
|
59
|
+
};
|
|
60
|
+
for (const pattern of patterns) {
|
|
61
|
+
if (pattern.startsWith("!"))
|
|
62
|
+
continue;
|
|
63
|
+
if (pattern.includes("**")) {
|
|
64
|
+
addPackagesUnder(pattern.slice(0, pattern.indexOf("**")).replace(/\/$/, ""), 3);
|
|
65
|
+
}
|
|
66
|
+
else if (pattern.endsWith("/*")) {
|
|
67
|
+
addPackagesUnder(pattern.slice(0, -2), 1);
|
|
68
|
+
}
|
|
69
|
+
else if (fs.existsSync(path.join(root, pattern, "package.json"))) {
|
|
70
|
+
dirs.add(pattern);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Repos without workspace config often still split into conventional
|
|
74
|
+
// app dirs (frontend/ + backend/, client/ + server/). Treating those as
|
|
75
|
+
// pseudo-workspaces makes env/script checks see the whole project.
|
|
76
|
+
if (dirs.size === 0) {
|
|
77
|
+
for (const conventional of ["frontend", "backend", "client", "server", "web", "app", "api"]) {
|
|
78
|
+
if (fs.existsSync(path.join(root, conventional, "package.json"))) {
|
|
79
|
+
dirs.add(conventional);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return [...dirs].sort();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Detects non-JS languages living alongside the primary framework, so a
|
|
87
|
+
* FastAPI + React template reads "Vite + Python" instead of hiding half
|
|
88
|
+
* the project.
|
|
89
|
+
*/
|
|
90
|
+
export function detectExtraLanguages(root, framework) {
|
|
91
|
+
const markers = [
|
|
92
|
+
["pyproject.toml", "Python"],
|
|
93
|
+
["requirements.txt", "Python"],
|
|
94
|
+
["go.mod", "Go"],
|
|
95
|
+
["Cargo.toml", "Rust"],
|
|
96
|
+
["Gemfile", "Ruby"],
|
|
97
|
+
["composer.json", "PHP"],
|
|
98
|
+
];
|
|
99
|
+
const found = new Set();
|
|
100
|
+
for (const [file, lang] of markers) {
|
|
101
|
+
if (lang !== framework && fs.existsSync(path.join(root, file)))
|
|
102
|
+
found.add(lang);
|
|
103
|
+
}
|
|
104
|
+
return [...found];
|
|
105
|
+
}
|
|
2
106
|
/** Detects the package manager based on lockfiles and manifests. */
|
|
3
107
|
export function detectPackageManager(root) {
|
|
4
108
|
// Node ecosystems (lockfile wins over manifest)
|
|
@@ -45,12 +149,26 @@ function countByExt(files, exts) {
|
|
|
45
149
|
* that static sites, Python, Go, Rust, PHP, and Ruby projects are
|
|
46
150
|
* identified instead of reported as "unknown".
|
|
47
151
|
*/
|
|
48
|
-
export function detectFramework(pkg, root, sourceFiles = []) {
|
|
152
|
+
export function detectFramework(pkg, root, sourceFiles = [], workspaceDirs = []) {
|
|
49
153
|
if (pkg) {
|
|
50
154
|
const deps = {
|
|
51
155
|
...pkg.dependencies,
|
|
52
156
|
...pkg.devDependencies,
|
|
53
157
|
};
|
|
158
|
+
// Monorepo roots rarely depend on the framework directly - check the
|
|
159
|
+
// workspace packages so a Turborepo of Next.js apps reads "Next.js",
|
|
160
|
+
// not "Node.js".
|
|
161
|
+
if (root && workspaceDirs.length > 0 && !deps["next"] && !deps["react"] && !deps["vue"] && !deps["svelte"]) {
|
|
162
|
+
for (const dir of workspaceDirs) {
|
|
163
|
+
try {
|
|
164
|
+
const wsPkg = JSON.parse(fs.readFileSync(path.join(root, dir, "package.json"), "utf8"));
|
|
165
|
+
const wsResult = detectFramework(wsPkg, undefined, []);
|
|
166
|
+
if (wsResult !== "Node.js")
|
|
167
|
+
return wsResult;
|
|
168
|
+
}
|
|
169
|
+
catch { /* skip unreadable workspace */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
54
172
|
// Order matters: meta-frameworks before their underlying libraries.
|
|
55
173
|
if (deps["next"])
|
|
56
174
|
return "Next.js";
|
package/dist/utils/report.js
CHANGED
|
@@ -125,6 +125,7 @@ export function renderReport(report, verbose = false, version) {
|
|
|
125
125
|
: project.packageManager;
|
|
126
126
|
const meta = pc.dim("project ") +
|
|
127
127
|
project.framework +
|
|
128
|
+
(project.extraLanguages.length > 0 ? " + " + project.extraLanguages.join(" + ") : "") +
|
|
128
129
|
pc.dim(" \u00b7 ") +
|
|
129
130
|
pc.dim("pm ") +
|
|
130
131
|
pmLabel;
|
|
@@ -132,6 +133,12 @@ export function renderReport(report, verbose = false, version) {
|
|
|
132
133
|
lines.push(...box([title, meta]));
|
|
133
134
|
lines.push("");
|
|
134
135
|
lines.push(` ${pc.bold("Score")} ${scoreBar(report.score)} ${scoreLabel(report.score)} ${verdict(report.score)}`);
|
|
136
|
+
if (report.deductions.length > 0) {
|
|
137
|
+
lines.push(" " +
|
|
138
|
+
pc.dim(report.deductions
|
|
139
|
+
.map((d) => `-${d.points} ${d.reason}`)
|
|
140
|
+
.join(" \u00b7 ")));
|
|
141
|
+
}
|
|
135
142
|
lines.push("");
|
|
136
143
|
// Aligned checks table: one row per check, then its top-level findings.
|
|
137
144
|
const labelWidth = Math.max(...report.results.map((r) => (CHECK_LABELS[r.name] ?? r.name).length));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shipready",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Pre-flight check for your repo before shipping. Scans AI-coded projects for secrets, env issues, debug leftovers, and generates AI-agent instruction files.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|