shipready 1.5.2 → 1.6.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.
@@ -61,7 +61,7 @@ export function findRealLookingExampleValues(content) {
61
61
  if (eq <= 0)
62
62
  continue;
63
63
  const key = line.slice(0, eq).trim();
64
- let value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
64
+ const value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
65
65
  if (!value)
66
66
  continue;
67
67
  const lower = value.toLowerCase();
@@ -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",
@@ -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,30 @@ 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) {
30
+ if (!framework || isNodeEcosystem(framework))
31
+ return IMPORTANT_IGNORES;
32
+ return ECOSYSTEM_IGNORES[framework] ?? [".env"];
33
+ }
10
34
  /** Returns which important entries are missing from gitignore content. */
11
- export function missingIgnoreEntries(content) {
35
+ export function missingIgnoreEntries(content, framework) {
12
36
  const lines = content
13
37
  .split("\n")
14
38
  .map((l) => l.trim())
@@ -23,10 +47,10 @@ export function missingIgnoreEntries(content) {
23
47
  // "node_modules/" style already normalized above
24
48
  return false;
25
49
  });
26
- return IMPORTANT_IGNORES.filter((e) => !covers(e));
50
+ return importantIgnoresFor(framework).filter((e) => !covers(e));
27
51
  }
28
52
  /** Checks .gitignore existence and important entries. */
29
- export function checkGitignore(root) {
53
+ export function checkGitignore(root, framework) {
30
54
  const findings = [];
31
55
  const content = readTextFile(root, ".gitignore");
32
56
  if (content === null) {
@@ -37,7 +61,7 @@ export function checkGitignore(root) {
37
61
  });
38
62
  return { name: "gitignore", findings };
39
63
  }
40
- const missing = missingIgnoreEntries(content);
64
+ const missing = missingIgnoreEntries(content, framework);
41
65
  if (missing.length > 0) {
42
66
  findings.push({
43
67
  severity: "warning",
@@ -1,5 +1,21 @@
1
+ import { fileExists } from "../utils/files.js";
1
2
  import { isNodeEcosystem } from "../utils/framework.js";
2
3
  const IMPORTANT_SCRIPTS = ["dev", "build", "test", "lint"];
4
+ /** Linter configs that count as lint coverage even without a lint script. */
5
+ const LINTER_CONFIGS = [
6
+ "biome.json",
7
+ "biome.jsonc",
8
+ "eslint.config.js",
9
+ "eslint.config.mjs",
10
+ "eslint.config.cjs",
11
+ "eslint.config.ts",
12
+ ".eslintrc",
13
+ ".eslintrc.js",
14
+ ".eslintrc.cjs",
15
+ ".eslintrc.json",
16
+ ".eslintrc.yml",
17
+ ".eslintrc.yaml",
18
+ ];
3
19
  /** Checks package.json existence and important scripts. */
4
20
  export function checkPackageJson(project) {
5
21
  const findings = [];
@@ -26,7 +42,16 @@ export function checkPackageJson(project) {
26
42
  rule: "package-json.found",
27
43
  message: "package.json found",
28
44
  });
29
- const missing = IMPORTANT_SCRIPTS.filter((s) => !project.scripts[s]);
45
+ const hasLinterConfig = LINTER_CONFIGS.some((f) => fileExists(project.root, f));
46
+ const missing = IMPORTANT_SCRIPTS.filter((s) => {
47
+ if (project.scripts[s])
48
+ return false;
49
+ // A biome/eslint config means linting is set up; the script name is
50
+ // a convention, not a requirement (e.g. Biome repos run "biome check").
51
+ if (s === "lint" && hasLinterConfig)
52
+ return false;
53
+ return true;
54
+ });
30
55
  if (missing.length > 0) {
31
56
  findings.push({
32
57
  severity: "warning",
@@ -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
- ".env",
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 { 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,16 @@ 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,
32
34
  };
33
35
  }
34
36
  /** Extracts env usages, secrets, and todos from all source files. */
@@ -133,11 +135,11 @@ export async function runScan(root, options = {}) {
133
135
  const rawResults = [
134
136
  checkPackageJson(project),
135
137
  checkReadme(root),
136
- checkEnv(root, envUsages),
138
+ checkEnv(root, envUsages, project.workspaceDirs),
137
139
  checkSecrets(secrets),
138
140
  ...(options.history ? [checkHistory(historySecrets, historyScanned)] : []),
139
141
  checkTodos(todos),
140
- checkGitignore(root),
142
+ checkGitignore(root, project.framework),
141
143
  ];
142
144
  // Disabled rules are removed before scoring so they don't affect the score.
143
145
  const results = applyDisabledRules(rawResults, config.disableRules);
@@ -1,4 +1,66 @@
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
+ for (const pattern of patterns) {
42
+ if (pattern.startsWith("!"))
43
+ 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 */ }
57
+ }
58
+ else if (fs.existsSync(path.join(root, pattern, "package.json"))) {
59
+ dirs.add(pattern);
60
+ }
61
+ }
62
+ return [...dirs].sort();
63
+ }
2
64
  /** Detects the package manager based on lockfiles and manifests. */
3
65
  export function detectPackageManager(root) {
4
66
  // Node ecosystems (lockfile wins over manifest)
@@ -45,12 +107,26 @@ function countByExt(files, exts) {
45
107
  * that static sites, Python, Go, Rust, PHP, and Ruby projects are
46
108
  * identified instead of reported as "unknown".
47
109
  */
48
- export function detectFramework(pkg, root, sourceFiles = []) {
110
+ export function detectFramework(pkg, root, sourceFiles = [], workspaceDirs = []) {
49
111
  if (pkg) {
50
112
  const deps = {
51
113
  ...pkg.dependencies,
52
114
  ...pkg.devDependencies,
53
115
  };
116
+ // Monorepo roots rarely depend on the framework directly - check the
117
+ // workspace packages so a Turborepo of Next.js apps reads "Next.js",
118
+ // not "Node.js".
119
+ if (root && workspaceDirs.length > 0 && !deps["next"] && !deps["react"] && !deps["vue"] && !deps["svelte"]) {
120
+ for (const dir of workspaceDirs) {
121
+ try {
122
+ const wsPkg = JSON.parse(fs.readFileSync(path.join(root, dir, "package.json"), "utf8"));
123
+ const wsResult = detectFramework(wsPkg, undefined, []);
124
+ if (wsResult !== "Node.js")
125
+ return wsResult;
126
+ }
127
+ catch { /* skip unreadable workspace */ }
128
+ }
129
+ }
54
130
  // Order matters: meta-frameworks before their underlying libraries.
55
131
  if (deps["next"])
56
132
  return "Next.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipready",
3
- "version": "1.5.2",
3
+ "version": "1.6.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",
@@ -59,9 +59,12 @@
59
59
  "picocolors": "^1.1.1"
60
60
  },
61
61
  "devDependencies": {
62
+ "@eslint/js": "^10.0.1",
62
63
  "@types/node": "^22.10.2",
64
+ "eslint": "^10.7.0",
63
65
  "tsx": "^4.19.2",
64
66
  "typescript": "^5.7.2",
67
+ "typescript-eslint": "^8.63.0",
65
68
  "vitest": "^2.1.8"
66
69
  }
67
70
  }