shipready 1.6.0 → 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 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:
@@ -26,13 +26,30 @@ const ECOSYSTEM_IGNORES = {
26
26
  unknown: [".env"],
27
27
  };
28
28
  /** Returns the entries a project of the given framework should ignore. */
29
- export function importantIgnoresFor(framework) {
30
- if (!framework || isNodeEcosystem(framework))
29
+ export function importantIgnoresFor(framework, ctx) {
30
+ // Without context, keep the legacy full list (also the config-less API).
31
+ if (!framework && !ctx)
31
32
  return IMPORTANT_IGNORES;
32
- return ECOSYSTEM_IGNORES[framework] ?? [".env"];
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;
33
50
  }
34
51
  /** Returns which important entries are missing from gitignore content. */
35
- export function missingIgnoreEntries(content, framework) {
52
+ export function missingIgnoreEntries(content, framework, ctx) {
36
53
  const lines = content
37
54
  .split("\n")
38
55
  .map((l) => l.trim())
@@ -47,10 +64,10 @@ export function missingIgnoreEntries(content, framework) {
47
64
  // "node_modules/" style already normalized above
48
65
  return false;
49
66
  });
50
- return importantIgnoresFor(framework).filter((e) => !covers(e));
67
+ return importantIgnoresFor(framework, ctx).filter((e) => !covers(e));
51
68
  }
52
69
  /** Checks .gitignore existence and important entries. */
53
- export function checkGitignore(root, framework) {
70
+ export function checkGitignore(root, framework, ctx) {
54
71
  const findings = [];
55
72
  const content = readTextFile(root, ".gitignore");
56
73
  if (content === null) {
@@ -61,7 +78,7 @@ export function checkGitignore(root, framework) {
61
78
  });
62
79
  return { name: "gitignore", findings };
63
80
  }
64
- const missing = missingIgnoreEntries(content, framework);
81
+ const missing = missingIgnoreEntries(content, framework, ctx);
65
82
  if (missing.length > 0) {
66
83
  findings.push({
67
84
  severity: "warning",
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { fileExists } from "../utils/files.js";
2
4
  import { isNodeEcosystem } from "../utils/framework.js";
3
5
  const IMPORTANT_SCRIPTS = ["dev", "build", "test", "lint"];
@@ -43,9 +45,27 @@ export function checkPackageJson(project) {
43
45
  message: "package.json found",
44
46
  });
45
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
+ }
46
62
  const missing = IMPORTANT_SCRIPTS.filter((s) => {
47
63
  if (project.scripts[s])
48
64
  return false;
65
+ if (workspaceScripts.has(s))
66
+ return false;
67
+ if (isLibrary && (s === "dev" || s === "build"))
68
+ return false;
49
69
  // A biome/eslint config means linting is set up; the script name is
50
70
  // a convention, not a requirement (e.g. Biome repos run "biome check").
51
71
  if (s === "lint" && hasLinterConfig)
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, findWorkspaceDirs } 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([
@@ -31,6 +31,7 @@ export async function detectProject(root, config) {
31
31
  scripts: pkg?.scripts ?? {},
32
32
  sourceFiles,
33
33
  workspaceDirs,
34
+ extraLanguages: detectExtraLanguages(root, detectFramework(pkg, root, sourceFiles, workspaceDirs)),
34
35
  };
35
36
  }
36
37
  /** Extracts env usages, secrets, and todos from all source files. */
@@ -75,37 +76,50 @@ export function scanFiles(root, files, secretAllowlist = []) {
75
76
  }
76
77
  return { envUsages, secrets, todos };
77
78
  }
78
- /** Score deductions per the shipready scoring model. */
79
- export function calculateScore(results) {
80
- let score = 100;
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) {
81
85
  const all = results.flatMap((r) => r.findings);
82
86
  const has = (rule) => all.some((f) => f.rule === rule);
83
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
+ };
84
93
  if (has("package-json.missing"))
85
- score -= 20;
94
+ deduct(20, "no package.json");
86
95
  if (has("readme.missing"))
87
- score -= 15;
96
+ deduct(15, "no README");
88
97
  if (has("readme.weak"))
89
- score -= 8;
98
+ deduct(8, "weak README");
90
99
  // Missing scripts: look at message to determine which ones
91
100
  const scriptsFinding = all.find((f) => f.rule === "scripts.missing");
92
101
  if (scriptsFinding) {
93
102
  if (scriptsFinding.message.includes("build"))
94
- score -= 8;
103
+ deduct(8, "no build script");
95
104
  if (scriptsFinding.message.includes("test"))
96
- score -= 6;
105
+ deduct(6, "no test script");
97
106
  }
98
107
  if (has("env.example-missing"))
99
- score -= 10;
108
+ deduct(10, ".env.example missing");
100
109
  if (has("env.not-ignored"))
101
- score -= 15;
110
+ deduct(15, ".env not ignored");
102
111
  const secretCount = count("secrets.detected-item");
103
- score -= Math.min(secretCount * 25, 50);
112
+ deduct(Math.min(secretCount * 25, 50), secretCount === 1 ? "hardcoded secret" : `hardcoded secrets (${secretCount})`);
104
113
  const historyCount = count("history.secret-item");
105
- score -= Math.min(historyCount * 10, 30);
114
+ deduct(Math.min(historyCount * 10, 30), historyCount === 1 ? "secret in git history" : `secrets in git history (${historyCount})`);
106
115
  const todoCount = count("todos.item");
107
- score -= Math.min(todoCount * 2, 15);
108
- return Math.max(0, score);
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;
109
123
  }
110
124
  /** Removes findings whose rules are disabled by config. */
111
125
  function applyDisabledRules(results, disableRules) {
@@ -139,13 +153,16 @@ export async function runScan(root, options = {}) {
139
153
  checkSecrets(secrets),
140
154
  ...(options.history ? [checkHistory(historySecrets, historyScanned)] : []),
141
155
  checkTodos(todos),
142
- checkGitignore(root, project.framework),
156
+ checkGitignore(root, project.framework, {
157
+ hasBuildScript: Boolean(project.scripts["build"]),
158
+ usesEnv: envUsages.length > 0,
159
+ }),
143
160
  ];
144
161
  // Disabled rules are removed before scoring so they don't affect the score.
145
162
  const results = applyDisabledRules(rawResults, config.disableRules);
146
163
  return {
147
164
  project,
148
165
  results,
149
- score: calculateScore(results),
166
+ ...calculateScoreBreakdown(results),
150
167
  };
151
168
  }
@@ -38,29 +38,71 @@ export function findWorkspaceDirs(root, pkg) {
38
38
  catch { /* unreadable yaml: treat as no workspaces */ }
39
39
  }
40
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
+ };
41
60
  for (const pattern of patterns) {
42
61
  if (pattern.startsWith("!"))
43
62
  continue;
44
- if (pattern.endsWith("/*")) {
45
- const base = pattern.slice(0, -2);
46
- const abs = path.join(root, base);
47
- if (!fs.existsSync(abs))
48
- continue;
49
- try {
50
- for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
51
- if (entry.isDirectory() && fs.existsSync(path.join(abs, entry.name, "package.json"))) {
52
- dirs.add(path.join(base, entry.name));
53
- }
54
- }
55
- }
56
- catch { /* unreadable dir */ }
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);
57
68
  }
58
69
  else if (fs.existsSync(path.join(root, pattern, "package.json"))) {
59
70
  dirs.add(pattern);
60
71
  }
61
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
+ }
62
83
  return [...dirs].sort();
63
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
+ }
64
106
  /** Detects the package manager based on lockfiles and manifests. */
65
107
  export function detectPackageManager(root) {
66
108
  // Node ecosystems (lockfile wins over manifest)
@@ -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.6.0",
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",