shipready 1.3.2 → 1.5.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
@@ -4,6 +4,7 @@
4
4
 
5
5
  [![CI](https://github.com/formalness/shipready/actions/workflows/ci.yml/badge.svg)](https://github.com/formalness/shipready/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/shipready.svg)](https://www.npmjs.com/package/shipready)
7
+ [![tests](https://img.shields.io/badge/tests-179%20passing-brightgreen.svg)](https://github.com/formalness/shipready/tree/main/tests)
7
8
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
9
  [![node >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
9
10
 
@@ -37,9 +38,10 @@ shipready check
37
38
  ## Usage
38
39
 
39
40
  ```bash
40
- shipready check [path] # scan a project (current dir by default)
41
- shipready init [path] # generate AI-agent instruction files
42
- shipready fix [path] # apply safe automatic fixes
41
+ shipready check [path] # scan a project (current dir by default)
42
+ shipready staged [path] # fast secrets-only scan of staged files (pre-commit)
43
+ shipready init [path] # generate AI-agent instruction files
44
+ shipready fix [path] # apply safe automatic fixes (--dry-run to preview)
43
45
  ```
44
46
 
45
47
  ## Commands
@@ -52,6 +54,7 @@ Scans the project and prints a report with a 0-100 score.
52
54
  | --- | --- |
53
55
  | `-v, --verbose` | show file and line locations for every finding |
54
56
  | `--json` | output the raw structured report as JSON (great for CI) |
57
+ | `--sarif` | output SARIF 2.1.0 for **GitHub code scanning** — findings become native PR alerts |
55
58
  | `--fix` | apply safe fixes, re-scan, and show the score before/after |
56
59
  | `--history` | also scan the **full git history** (all branches) for secrets that were committed and later removed |
57
60
  | `--verify` | check detected keys against provider APIs to see if they are **live right now** |
@@ -74,6 +77,27 @@ For 12+ providers (OpenAI, Anthropic, GitHub, GitLab, Stripe, SendGrid, Google,
74
77
 
75
78
  Verification requests contain only the key itself, go directly to the provider's official API host, and never mutate remote state. Nothing is ever sent to any third party.
76
79
 
80
+ ### `shipready staged [path]`
81
+
82
+ Fast, secrets-only scan of the files **staged for commit** — built for pre-commit hooks. It reads content from the git index (`git show :file`), so partially staged files are checked exactly as they would be committed, not as they sit on disk.
83
+
84
+ - High-confidence secrets **block the commit** (exit code 1)
85
+ - Medium-confidence findings warn but do not block
86
+ - TODO/console.log are deliberately not checked — hooks must be fast and only stop real dangers
87
+
88
+ Hook setup with [husky](https://typicode.github.io/husky/):
89
+
90
+ ```bash
91
+ npx husky init
92
+ echo "npx shipready staged" > .husky/pre-commit
93
+ ```
94
+
95
+ Or plain git:
96
+
97
+ ```bash
98
+ echo 'npx shipready staged' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
99
+ ```
100
+
77
101
  ### `shipready init [path]`
78
102
 
79
103
  Generates AI-agent instruction files based on your detected framework, package manager, and scripts:
@@ -94,6 +118,21 @@ Applies safe automatic fixes:
94
118
 
95
119
  It never deletes user code and never overwrites files without `--force`. A summary of changed files is printed at the end.
96
120
 
121
+ Pass `--dry-run` to preview exactly what would change — nothing is written to disk:
122
+
123
+ ```txt
124
+ shipready fix --dry-run
125
+
126
+ + would create .env.example
127
+ | # Environment variables used by this project.
128
+ | API_URL=
129
+ + would create .gitignore
130
+ | .env
131
+ | node_modules
132
+
133
+ 2 files would change. Run without --dry-run to apply.
134
+ ```
135
+
97
136
  ## Example output
98
137
 
99
138
  ```txt
@@ -194,7 +233,14 @@ If shipready flags a line you know is safe (a demo value, an already-revoked key
194
233
  const DEMO_TOKEN = "ghp_thisIsADocumentationExample000000"; // shipready-ignore
195
234
  ```
196
235
 
197
- The scanner skips any line containing `shipready-ignore`. For project-wide exceptions, prefer `secretAllowlist` in the config (see below) so the suppression is reviewable in one place.
236
+ The marker works for **all checks** secrets, `console.log`, `TODO`/`FIXME`, and `debugger` alike:
237
+
238
+ ```js
239
+ console.log("startup banner"); // shipready-ignore
240
+ // TODO: legacy, tracked in JIRA-123 - shipready-ignore
241
+ ```
242
+
243
+ For project-wide exceptions, prefer `secretAllowlist` in the config (see below) so the suppression is reviewable in one place.
198
244
 
199
245
  ## Configuration
200
246
 
@@ -251,6 +297,33 @@ Example with options:
251
297
  version: "1.0.3"
252
298
  ```
253
299
 
300
+ ### GitHub code scanning (SARIF)
301
+
302
+ Turn shipready findings into **native code scanning alerts** on pull requests:
303
+
304
+ ```yaml
305
+ # .github/workflows/shipready-sarif.yml
306
+ name: shipready code scanning
307
+ on: [push, pull_request]
308
+ permissions:
309
+ security-events: write
310
+ jobs:
311
+ scan:
312
+ runs-on: ubuntu-latest
313
+ steps:
314
+ - uses: actions/checkout@v4
315
+ - run: npx shipready check --sarif > shipready.sarif || true
316
+ - uses: github/codeql-action/upload-sarif@v3
317
+ with:
318
+ sarif_file: shipready.sarif
319
+ ```
320
+
321
+ Findings appear in the repository's **Security → Code scanning** tab and as inline PR annotations, with stable fingerprints so alerts track across pushes.
322
+
323
+ ### Pre-commit hook
324
+
325
+ Catch secrets **before** they enter history at all — see [`shipready staged`](#shipready-staged-path) above.
326
+
254
327
  ### Manual setup
255
328
 
256
329
  `shipready check` exits with code `1` when errors are found, so it works in any CI:
@@ -259,7 +332,7 @@ Example with options:
259
332
  - run: npx shipready check --verbose
260
333
  ```
261
334
 
262
- Use `--json` to feed the report into other tooling.
335
+ Use `--json` to feed the report into other tooling, or `--sarif` for anything that speaks SARIF.
263
336
 
264
337
  ## Scoring
265
338
 
@@ -292,10 +365,13 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for project structure and how to add ne
292
365
 
293
366
  ## Roadmap
294
367
 
368
+ - [x] `shipready staged` for pre-commit hooks
369
+ - [x] SARIF report output for GitHub code scanning
370
+ - [x] Git history scanning (`--history`)
371
+ - [x] Live key verification (`--verify`)
372
+ - [ ] Benchmark against gitleaks/trufflehog with published numbers
373
+ - [ ] Framework-aware scoring (Next.js vs Vite vs Python have different needs)
295
374
  - [ ] Optional AI-powered fix suggestions (bring your own key)
296
- - [ ] `shipready check --staged` for pre-commit hooks
297
- - [ ] Markdown/SARIF report output for GitHub code scanning
298
- - [ ] More frameworks and secret providers
299
375
 
300
376
  ## License
301
377
 
@@ -1,8 +1,19 @@
1
+ import { isNodeEcosystem } from "../utils/framework.js";
1
2
  const IMPORTANT_SCRIPTS = ["dev", "build", "test", "lint"];
2
3
  /** Checks package.json existence and important scripts. */
3
4
  export function checkPackageJson(project) {
4
5
  const findings = [];
5
6
  if (!project.hasPackageJson) {
7
+ // Static sites, Python, Go, Rust, etc. don't need a package.json -
8
+ // penalizing them for it would be unfair noise.
9
+ if (!isNodeEcosystem(project.framework)) {
10
+ findings.push({
11
+ severity: "info",
12
+ rule: "package-json.not-applicable",
13
+ message: `package.json not required for a ${project.framework} project`,
14
+ });
15
+ return { name: "package.json", findings };
16
+ }
6
17
  findings.push({
7
18
  severity: "error",
8
19
  rule: "package-json.missing",
@@ -0,0 +1,55 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { scanContentForSecrets } from "./secrets.js";
3
+ /**
4
+ * Pre-commit mode: scans only the files staged in the git index.
5
+ * Reads content via `git show :<file>` so partially staged files are
6
+ * checked exactly as they would be committed, not as they sit on disk.
7
+ * Secrets-only by design - a pre-commit hook must be fast and only
8
+ * block on real dangers, not TODO comments.
9
+ */
10
+ /** Extensions and paths we skip in staged mode (binary/vendor noise). */
11
+ const STAGED_SKIP_RE = /(^|\/)(node_modules|vendor|dist|build|\.next|out|coverage)(\/|$)|\.(lock|min\.js|min\.css|map|png|jpg|jpeg|gif|webp|svg|ico|pdf|zip|gz|woff2?|ttf|eot|mp3|mp4)$|(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/i;
12
+ /** Lists files staged for commit (added/copied/modified/renamed). */
13
+ export function listStagedFiles(root) {
14
+ try {
15
+ const out = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd: root, encoding: "utf8" });
16
+ return out.split("\0").filter((f) => f.length > 0 && !STAGED_SKIP_RE.test(f));
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ }
22
+ /** Reads a file's staged (index) content, not the working-tree version. */
23
+ export function readStagedContent(root, file) {
24
+ try {
25
+ const buf = execFileSync("git", ["show", `:${file}`], {
26
+ cwd: root,
27
+ maxBuffer: 64 * 1024 * 1024,
28
+ });
29
+ // Binary sniff: NUL byte in the first 512 bytes.
30
+ const head = buf.subarray(0, 512);
31
+ for (const byte of head) {
32
+ if (byte === 0)
33
+ return null;
34
+ }
35
+ return buf.toString("utf8");
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Scans all staged files for secrets. */
42
+ export function scanStaged(root, allowlist = []) {
43
+ const files = listStagedFiles(root);
44
+ const secrets = [];
45
+ for (const file of files) {
46
+ // .env files are expected to hold real values; committing one at all is
47
+ // the problem, and the gitignore check covers that. But since it IS
48
+ // being committed here, flag everything inside it too.
49
+ const content = readStagedContent(root, file);
50
+ if (content === null)
51
+ continue;
52
+ secrets.push(...scanContentForSecrets(content, file, allowlist));
53
+ }
54
+ return { files, secrets };
55
+ }
@@ -13,6 +13,10 @@ export function scanContentForTodos(content, file) {
13
13
  const lines = content.split("\n");
14
14
  for (let i = 0; i < lines.length; i++) {
15
15
  const line = lines[i];
16
+ // Same suppression marker the secret scanner honors - one convention
17
+ // for all checks, so intentional console.log/TODO lines stay quiet.
18
+ if (line.includes("shipready-ignore"))
19
+ continue;
16
20
  const marker = line.match(MARKER_RE);
17
21
  if (marker) {
18
22
  found.push({ kind: "marker", label: marker[1], file, line: i + 1 });
package/dist/cli.js CHANGED
@@ -9,17 +9,27 @@ import { fixAgentFiles } from "./fixers/agentFiles.js";
9
9
  import { fixEnvExample } from "./fixers/envExample.js";
10
10
  import { fixGitignore } from "./fixers/gitignore.js";
11
11
  import { renderReport } from "./utils/report.js";
12
- function printFixResults(results) {
12
+ import { toSarif } from "./utils/sarif.js";
13
+ import { scanStaged } from "./checks/staged.js";
14
+ import { isGitRepo } from "./checks/history.js";
15
+ function printFixResults(results, showPreview = false) {
13
16
  for (const r of results) {
17
+ const verb = (v) => (r.dryRun ? `would ${v.replace(/ed$/, "e")}` : v);
14
18
  if (r.action === "created") {
15
- console.log(`${pc.green("+")} created ${pc.bold(r.file)}`);
19
+ console.log(`${pc.green("+")} ${verb("created")} ${pc.bold(r.file)}`);
16
20
  }
17
21
  else if (r.action === "updated") {
18
- console.log(`${pc.yellow("~")} updated ${pc.bold(r.file)}`);
22
+ console.log(`${pc.yellow("~")} ${verb("updated")} ${pc.bold(r.file)}`);
19
23
  }
20
24
  else {
21
25
  console.log(`${pc.dim("-")} skipped ${r.file}${r.reason ? pc.dim(` (${r.reason})`) : ""}`);
22
26
  }
27
+ if (showPreview && r.preview && r.action !== "skipped") {
28
+ const lines = r.preview.trimEnd().split("\n");
29
+ for (const l of lines) {
30
+ console.log(pc.dim(" | ") + pc.green(l));
31
+ }
32
+ }
23
33
  }
24
34
  }
25
35
  /** Resolves and validates the target directory argument. */
@@ -34,14 +44,14 @@ function resolveRoot(dir) {
34
44
  return root;
35
45
  }
36
46
  /** Applies all safe fixes for the given root; returns the results. */
37
- async function applyFixes(root, force) {
47
+ async function applyFixes(root, force, dryRun = false) {
38
48
  const config = loadConfig(root);
39
49
  const project = await detectProject(root, config);
40
50
  const { envUsages } = scanFiles(root, project.sourceFiles, config.secretAllowlist);
41
51
  return [
42
- fixEnvExample(root, envUsages, force),
43
- fixGitignore(root),
44
- ...fixAgentFiles(root, project, force),
52
+ fixEnvExample(root, envUsages, force, dryRun),
53
+ fixGitignore(root, dryRun),
54
+ ...fixAgentFiles(root, project, force, dryRun),
45
55
  ];
46
56
  }
47
57
  /** Reads the CLI's own version from its package.json (works from dist/ at runtime). */
@@ -68,14 +78,16 @@ export function buildProgram() {
68
78
  .argument("[path]", "project directory to scan (defaults to current directory)")
69
79
  .option("-v, --verbose", "show file locations for every finding")
70
80
  .option("--json", "output the raw report as JSON")
81
+ .option("--sarif", "output the report as SARIF 2.1.0 for GitHub code scanning")
71
82
  .option("--fix", "apply safe fixes, then re-scan and show the improved report")
72
83
  .option("--history", "also scan the full git history for leaked secrets")
73
84
  .option("--verify", "check detected keys against provider APIs to see if they are live")
74
85
  .action(async (dir, opts) => {
75
86
  try {
76
87
  const root = resolveRoot(dir);
88
+ const machineOutput = Boolean(opts.json || opts.sarif);
77
89
  const scanOpts = { history: opts.history, verify: opts.verify };
78
- if (opts.verify && !opts.json) {
90
+ if (opts.verify && !machineOutput) {
79
91
  console.log(pc.dim("\n Verifying detected keys against provider APIs..."));
80
92
  }
81
93
  let report = await runScan(root, scanOpts);
@@ -83,7 +95,7 @@ export function buildProgram() {
83
95
  const before = report.score;
84
96
  const results = await applyFixes(root, false);
85
97
  report = await runScan(root, scanOpts);
86
- if (!opts.json) {
98
+ if (!machineOutput) {
87
99
  console.log("");
88
100
  console.log(pc.bold(pc.magenta("shipready check --fix")));
89
101
  console.log("");
@@ -94,7 +106,10 @@ export function buildProgram() {
94
106
  (delta > 0 ? pc.green(` (+${delta})`) : pc.dim(" (no change)")));
95
107
  }
96
108
  }
97
- if (opts.json) {
109
+ if (opts.sarif) {
110
+ console.log(toSarif(report, ownVersion()));
111
+ }
112
+ else if (opts.json) {
98
113
  console.log(JSON.stringify(report, null, 2));
99
114
  }
100
115
  else {
@@ -140,19 +155,28 @@ export function buildProgram() {
140
155
  .description("Apply safe automatic fixes (.env.example, .gitignore, agent files)")
141
156
  .argument("[path]", "project directory (defaults to current directory)")
142
157
  .option("-f, --force", "overwrite existing files")
158
+ .option("--dry-run", "show what would change without writing anything")
143
159
  .action(async (dir, opts) => {
144
160
  try {
145
161
  const root = resolveRoot(dir);
162
+ const dryRun = opts.dryRun ?? false;
146
163
  console.log("");
147
- console.log(pc.bold(pc.magenta("shipready fix")));
164
+ console.log(pc.bold(pc.magenta(`shipready fix${dryRun ? " --dry-run" : ""}`)));
148
165
  console.log("");
149
- const results = await applyFixes(root, opts.force ?? false);
150
- printFixResults(results);
166
+ const results = await applyFixes(root, opts.force ?? false, dryRun);
167
+ printFixResults(results, dryRun);
151
168
  const changed = results.filter((r) => r.action !== "skipped").length;
152
169
  console.log("");
153
- console.log(changed > 0
154
- ? pc.green(`${changed} file${changed > 1 ? "s" : ""} changed.`)
155
- : pc.dim("Nothing to fix - everything already in place."));
170
+ if (dryRun) {
171
+ console.log(changed > 0
172
+ ? pc.yellow(`${changed} file${changed > 1 ? "s" : ""} would change. Run without --dry-run to apply.`)
173
+ : pc.dim("Nothing to fix - everything already in place."));
174
+ }
175
+ else {
176
+ console.log(changed > 0
177
+ ? pc.green(`${changed} file${changed > 1 ? "s" : ""} changed.`)
178
+ : pc.dim("Nothing to fix - everything already in place."));
179
+ }
156
180
  console.log("");
157
181
  }
158
182
  catch (err) {
@@ -160,6 +184,54 @@ export function buildProgram() {
160
184
  process.exitCode = 1;
161
185
  }
162
186
  });
187
+ program
188
+ .command("staged")
189
+ .description("Fast secrets-only scan of files staged for commit (pre-commit hook)")
190
+ .argument("[path]", "project directory (defaults to current directory)")
191
+ .action(async (dir) => {
192
+ try {
193
+ const root = resolveRoot(dir);
194
+ if (!isGitRepo(root)) {
195
+ console.error(pc.red("shipready staged: not a git repository"));
196
+ process.exitCode = 1;
197
+ return;
198
+ }
199
+ const config = loadConfig(root);
200
+ const { files, secrets } = scanStaged(root, config.secretAllowlist);
201
+ if (files.length === 0) {
202
+ console.log(pc.dim("shipready staged: no staged files to scan."));
203
+ return;
204
+ }
205
+ const high = secrets.filter((s) => s.confidence !== "medium");
206
+ const medium = secrets.filter((s) => s.confidence === "medium");
207
+ if (secrets.length === 0) {
208
+ console.log(pc.green("✓") +
209
+ ` shipready staged: ${files.length} file${files.length > 1 ? "s" : ""} clean.`);
210
+ return;
211
+ }
212
+ console.log("");
213
+ for (const s of high) {
214
+ console.log(`${pc.red("✗")} ${s.kind}: ${s.masked} ${pc.dim(`${s.file}:${s.line}`)}`);
215
+ }
216
+ for (const s of medium) {
217
+ console.log(`${pc.yellow("⚠")} ${s.kind}: ${s.masked} ${pc.dim(`${s.file}:${s.line}`)} ${pc.dim("(low confidence)")}`);
218
+ }
219
+ console.log("");
220
+ if (high.length > 0) {
221
+ console.log(pc.red(`Blocked: ${high.length} secret${high.length > 1 ? "s" : ""} in staged files. ` +
222
+ `Remove them (or add // shipready-ignore for intentional values) and retry.`));
223
+ // Only high-confidence findings block the commit; medium is a warning.
224
+ process.exitCode = 1;
225
+ }
226
+ else {
227
+ console.log(pc.yellow("Warning only - commit not blocked."));
228
+ }
229
+ }
230
+ catch (err) {
231
+ console.error(pc.red(`shipready staged failed: ${err.message}`));
232
+ process.exitCode = 1;
233
+ }
234
+ });
163
235
  return program;
164
236
  }
165
237
  /** CLI entry: parses argv and runs the matching command. */
@@ -8,7 +8,7 @@ const TARGETS = [
8
8
  { file: ".cursor/rules/shipready.md", generate: generateCursorRules },
9
9
  ];
10
10
  /** Generates all agent instruction files, honoring --force. */
11
- export function fixAgentFiles(root, project, force) {
11
+ export function fixAgentFiles(root, project, force, dryRun = false) {
12
12
  const results = [];
13
13
  for (const { file, generate } of TARGETS) {
14
14
  if (fileExists(root, file) && !force) {
@@ -16,8 +16,11 @@ export function fixAgentFiles(root, project, force) {
16
16
  continue;
17
17
  }
18
18
  const existed = fileExists(root, file);
19
- writeTextFile(root, file, generate(project));
20
- results.push({ file, action: existed ? "updated" : "created" });
19
+ if (!dryRun) {
20
+ writeTextFile(root, file, generate(project));
21
+ }
22
+ // Agent files are long; the dry-run preview stays terse on purpose.
23
+ results.push({ file, action: existed ? "updated" : "created", dryRun });
21
24
  }
22
25
  return results;
23
26
  }
@@ -12,7 +12,7 @@ export function buildEnvExample(usages) {
12
12
  return lines.join("\n");
13
13
  }
14
14
  /** Creates .env.example if missing (or with force). */
15
- export function fixEnvExample(root, usages, force) {
15
+ export function fixEnvExample(root, usages, force, dryRun = false) {
16
16
  const file = ".env.example";
17
17
  const names = [...new Set(usages.map((u) => u.name))];
18
18
  if (names.length === 0) {
@@ -22,6 +22,14 @@ export function fixEnvExample(root, usages, force) {
22
22
  return { file, action: "skipped", reason: "already exists (use --force)" };
23
23
  }
24
24
  const existed = fileExists(root, file);
25
- writeTextFile(root, file, buildEnvExample(usages));
26
- return { file, action: existed ? "updated" : "created" };
25
+ const content = buildEnvExample(usages);
26
+ if (!dryRun) {
27
+ writeTextFile(root, file, content);
28
+ }
29
+ return {
30
+ file,
31
+ action: existed ? "updated" : "created",
32
+ dryRun,
33
+ preview: content,
34
+ };
27
35
  }
@@ -1,7 +1,7 @@
1
1
  import { 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) {
4
+ export function fixGitignore(root, dryRun = false) {
5
5
  const file = ".gitignore";
6
6
  const existing = readTextFile(root, file);
7
7
  if (existing === null) {
@@ -15,8 +15,10 @@ export function fixGitignore(root) {
15
15
  ".next",
16
16
  "",
17
17
  ].join("\n");
18
- writeTextFile(root, file, content);
19
- return { file, action: "created" };
18
+ if (!dryRun) {
19
+ writeTextFile(root, file, content);
20
+ }
21
+ return { file, action: "created", dryRun, preview: content };
20
22
  }
21
23
  const missing = missingIgnoreEntries(existing);
22
24
  if (missing.length === 0) {
@@ -24,6 +26,9 @@ export function fixGitignore(root) {
24
26
  }
25
27
  const suffix = existing.endsWith("\n") ? "" : "\n";
26
28
  const addition = `${suffix}\n# Added by shipready\n${missing.join("\n")}\n`;
27
- writeTextFile(root, file, existing + addition);
28
- return { file, action: "updated" };
29
+ if (!dryRun) {
30
+ writeTextFile(root, file, existing + addition);
31
+ }
32
+ // Preview shows only what gets appended, not the whole file.
33
+ return { file, action: "updated", dryRun, preview: addition.trimStart() };
29
34
  }
package/dist/scanner.js CHANGED
@@ -14,6 +14,8 @@ import { isRuleDisabled, loadConfig } from "./config.js";
14
14
  const CODE_ONLY = new Set([
15
15
  ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts",
16
16
  ".vue", ".svelte", ".py", ".rb", ".go", ".rs", ".java", ".php",
17
+ // Inline <script> in static sites carries console.log/TODO noise too
18
+ ".html", ".htm",
17
19
  ]);
18
20
  /** Gathers structured project info from the given root. */
19
21
  export async function detectProject(root, config) {
@@ -24,7 +26,7 @@ export async function detectProject(root, config) {
24
26
  hasPackageJson: fileExists(root, "package.json"),
25
27
  packageJson: pkg,
26
28
  packageManager: detectPackageManager(root),
27
- framework: detectFramework(pkg),
29
+ framework: detectFramework(pkg, root, sourceFiles),
28
30
  scripts: pkg?.scripts ?? {},
29
31
  sourceFiles,
30
32
  };
@@ -3,14 +3,34 @@ import path from "node:path";
3
3
  import fg from "fast-glob";
4
4
  /** Glob patterns that are always ignored when scanning. */
5
5
  export const IGNORE_PATTERNS = [
6
- "node_modules/**",
7
- ".git/**",
8
- "dist/**",
9
- "build/**",
10
- ".next/**",
11
- "out/**",
12
- "coverage/**",
13
- ".cache/**",
6
+ // Dependencies and VCS (at any depth)
7
+ "**/node_modules/**",
8
+ "**/.git/**",
9
+ // Build output (at any depth, e.g. mobile/build/)
10
+ "**/dist/**",
11
+ "**/build/**",
12
+ "**/.next/**",
13
+ "**/out/**",
14
+ "**/coverage/**",
15
+ "**/.cache/**",
16
+ "**/.turbo/**",
17
+ "**/.output/**",
18
+ // Minified/bundled vendor assets — not user code
19
+ "**/*.min.js",
20
+ "**/*.min.css",
21
+ "**/*.bundle.js",
22
+ "**/*.chunk.js",
23
+ "**/vendor/**",
24
+ "**/vendors/**",
25
+ // Python artifacts
26
+ "**/__pycache__/**",
27
+ "**/.venv/**",
28
+ "**/venv/**",
29
+ // Lockfiles and maps
30
+ "**/*.map",
31
+ "**/package-lock.json",
32
+ "**/pnpm-lock.yaml",
33
+ "**/yarn.lock",
14
34
  ];
15
35
  /** Extensions we treat as scannable text/code files. */
16
36
  export const CODE_EXTENSIONS = [
@@ -36,6 +56,9 @@ export const CODE_EXTENSIONS = [
36
56
  "toml",
37
57
  "env",
38
58
  "sh",
59
+ // Static sites: inline <script> blocks can leak keys just as easily
60
+ "html",
61
+ "htm",
39
62
  ];
40
63
  /** Returns true if the file exists (relative to root or absolute). */
41
64
  export function fileExists(root, rel) {
@@ -1,6 +1,7 @@
1
1
  import { fileExists } from "./files.js";
2
- /** Detects the package manager based on lockfiles. */
2
+ /** Detects the package manager based on lockfiles and manifests. */
3
3
  export function detectPackageManager(root) {
4
+ // Node ecosystems (lockfile wins over manifest)
4
5
  if (fileExists(root, "pnpm-lock.yaml"))
5
6
  return "pnpm";
6
7
  if (fileExists(root, "yarn.lock"))
@@ -9,34 +10,131 @@ export function detectPackageManager(root) {
9
10
  return "bun";
10
11
  if (fileExists(root, "package-lock.json"))
11
12
  return "npm";
13
+ if (fileExists(root, "deno.json") || fileExists(root, "deno.jsonc"))
14
+ return "deno";
15
+ // package.json without a lockfile is still an npm-family project
16
+ if (fileExists(root, "package.json"))
17
+ return "npm";
18
+ // Other ecosystems
19
+ if (fileExists(root, "uv.lock"))
20
+ return "uv";
21
+ if (fileExists(root, "poetry.lock"))
22
+ return "poetry";
23
+ if (fileExists(root, "requirements.txt") ||
24
+ fileExists(root, "pyproject.toml") ||
25
+ fileExists(root, "Pipfile")) {
26
+ return "pip";
27
+ }
28
+ if (fileExists(root, "Cargo.toml"))
29
+ return "cargo";
30
+ if (fileExists(root, "go.mod"))
31
+ return "go";
32
+ if (fileExists(root, "composer.json"))
33
+ return "composer";
34
+ if (fileExists(root, "Gemfile"))
35
+ return "bundler";
36
+ return "none";
37
+ }
38
+ /** Counts source files matching any of the given extensions. */
39
+ function countByExt(files, exts) {
40
+ return files.filter((f) => exts.some((e) => f.toLowerCase().endsWith(e))).length;
41
+ }
42
+ /**
43
+ * Detects the framework/project type. Checks package.json dependencies
44
+ * first, then falls back to manifest files and source file extensions so
45
+ * that static sites, Python, Go, Rust, PHP, and Ruby projects are
46
+ * identified instead of reported as "unknown".
47
+ */
48
+ export function detectFramework(pkg, root, sourceFiles = []) {
49
+ if (pkg) {
50
+ const deps = {
51
+ ...pkg.dependencies,
52
+ ...pkg.devDependencies,
53
+ };
54
+ // Order matters: meta-frameworks before their underlying libraries.
55
+ if (deps["next"])
56
+ return "Next.js";
57
+ if (deps["astro"])
58
+ return "Astro";
59
+ if (deps["@remix-run/react"] || deps["@remix-run/node"])
60
+ return "Remix";
61
+ if (deps["@angular/core"])
62
+ return "Angular";
63
+ if (deps["gatsby"])
64
+ return "Gatsby";
65
+ if (deps["@nestjs/core"])
66
+ return "NestJS";
67
+ if (deps["nuxt"] || deps["vue"])
68
+ return "Vue";
69
+ if (deps["svelte"] || deps["@sveltejs/kit"])
70
+ return "Svelte";
71
+ if (deps["vite"])
72
+ return "Vite";
73
+ if (deps["react"])
74
+ return "React";
75
+ if (deps["express"])
76
+ return "Express";
77
+ return "Node.js";
78
+ }
79
+ // No package.json: detect by manifests, then by dominant file type.
80
+ if (root) {
81
+ if (fileExists(root, "deno.json") || fileExists(root, "deno.jsonc"))
82
+ return "Deno";
83
+ if (fileExists(root, "pyproject.toml") ||
84
+ fileExists(root, "requirements.txt") ||
85
+ fileExists(root, "Pipfile")) {
86
+ return "Python";
87
+ }
88
+ if (fileExists(root, "Cargo.toml"))
89
+ return "Rust";
90
+ if (fileExists(root, "go.mod"))
91
+ return "Go";
92
+ if (fileExists(root, "composer.json"))
93
+ return "PHP";
94
+ if (fileExists(root, "Gemfile"))
95
+ return "Ruby";
96
+ if (fileExists(root, "pom.xml") || fileExists(root, "build.gradle") || fileExists(root, "build.gradle.kts")) {
97
+ return "Java";
98
+ }
99
+ }
100
+ const html = countByExt(sourceFiles, [".html", ".htm"]);
101
+ const py = countByExt(sourceFiles, [".py"]);
102
+ const go = countByExt(sourceFiles, [".go"]);
103
+ const rs = countByExt(sourceFiles, [".rs"]);
104
+ const php = countByExt(sourceFiles, [".php"]);
105
+ const rb = countByExt(sourceFiles, [".rb"]);
106
+ const js = countByExt(sourceFiles, [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
107
+ const counts = [
108
+ ["Python", py],
109
+ ["Go", go],
110
+ ["Rust", rs],
111
+ ["PHP", php],
112
+ ["Ruby", rb],
113
+ ];
114
+ const [lang, max] = counts.reduce((a, b) => (b[1] > a[1] ? b : a), ["unknown", 0]);
115
+ // A language wins only when it clearly dominates over HTML/JS glue files.
116
+ if (max > 0 && max >= html && max >= js)
117
+ return lang;
118
+ // HTML pages with (or without) plain JS assets: a static site.
119
+ if (html > 0)
120
+ return "Static HTML";
121
+ if (js > 0)
122
+ return "Node.js";
12
123
  return "unknown";
13
124
  }
14
- /** Detects the framework from package.json dependencies. */
15
- export function detectFramework(pkg) {
16
- if (!pkg)
17
- return "unknown";
18
- const deps = {
19
- ...pkg.dependencies,
20
- ...pkg.devDependencies,
21
- };
22
- // Order matters: meta-frameworks before their underlying libraries.
23
- if (deps["next"])
24
- return "Next.js";
25
- if (deps["@nestjs/core"])
26
- return "NestJS";
27
- if (deps["nuxt"] || deps["vue"])
28
- return "Vue";
29
- if (deps["svelte"] || deps["@sveltejs/kit"])
30
- return "Svelte";
31
- if (deps["vite"] && deps["react"])
32
- return "Vite";
33
- if (deps["vite"])
34
- return "Vite";
35
- if (deps["react"])
36
- return "React";
37
- if (deps["express"])
38
- return "Express";
39
- return "Node.js";
125
+ /** True when the project belongs to the npm/Node ecosystem. */
126
+ export function isNodeEcosystem(framework) {
127
+ return ![
128
+ "Static HTML",
129
+ "Python",
130
+ "Go",
131
+ "Rust",
132
+ "PHP",
133
+ "Ruby",
134
+ "Java",
135
+ "Deno",
136
+ "unknown",
137
+ ].includes(framework);
40
138
  }
41
139
  /** Returns the install command for a given package manager. */
42
140
  export function installCommand(pm) {
@@ -47,8 +145,21 @@ export function installCommand(pm) {
47
145
  return "yarn install";
48
146
  case "bun":
49
147
  return "bun install";
50
- case "npm":
51
- case "unknown":
148
+ case "poetry":
149
+ return "poetry install";
150
+ case "uv":
151
+ return "uv sync";
152
+ case "pip":
153
+ return "pip install -r requirements.txt";
154
+ case "cargo":
155
+ return "cargo build";
156
+ case "go":
157
+ return "go mod download";
158
+ case "composer":
159
+ return "composer install";
160
+ case "bundler":
161
+ return "bundle install";
162
+ default:
52
163
  return "npm install";
53
164
  }
54
165
  }
@@ -61,8 +172,7 @@ export function runCommand(pm, script) {
61
172
  return `yarn ${script}`;
62
173
  case "bun":
63
174
  return `bun run ${script}`;
64
- case "npm":
65
- case "unknown":
175
+ default:
66
176
  return `npm run ${script}`;
67
177
  }
68
178
  }
@@ -120,11 +120,14 @@ export function renderReport(report, verbose = false, version) {
120
120
  const lines = [];
121
121
  const { project } = report;
122
122
  const title = pc.bold(pc.magenta("shipready")) + (version ? pc.dim(` v${version}`) : "");
123
+ const pmLabel = project.packageManager === "none" || project.packageManager === "unknown"
124
+ ? pc.dim("\u2014")
125
+ : project.packageManager;
123
126
  const meta = pc.dim("project ") +
124
127
  project.framework +
125
128
  pc.dim(" \u00b7 ") +
126
129
  pc.dim("pm ") +
127
- project.packageManager;
130
+ pmLabel;
128
131
  lines.push("");
129
132
  lines.push(...box([title, meta]));
130
133
  lines.push("");
@@ -0,0 +1,85 @@
1
+ /** Maps shipready severity to a SARIF level. */
2
+ function toLevel(severity) {
3
+ if (severity === "error")
4
+ return "error";
5
+ if (severity === "warning")
6
+ return "warning";
7
+ return "note";
8
+ }
9
+ /** Human-friendly rule names derived from rule ids ("secrets.detected-item" -> "SecretsDetectedItem"). */
10
+ function ruleName(ruleId) {
11
+ return ruleId
12
+ .split(/[.-]/)
13
+ .filter(Boolean)
14
+ .map((p) => p[0].toUpperCase() + p.slice(1))
15
+ .join("");
16
+ }
17
+ /** Simple stable fingerprint so GitHub tracks alerts across pushes. */
18
+ function fingerprint(f) {
19
+ const raw = `${f.rule}|${f.file ?? ""}|${f.message}`;
20
+ let hash = 0;
21
+ for (let i = 0; i < raw.length; i++) {
22
+ hash = (hash * 31 + raw.charCodeAt(i)) | 0;
23
+ }
24
+ return (hash >>> 0).toString(16);
25
+ }
26
+ /** Converts a shipready report to a SARIF 2.1.0 log string. */
27
+ export function toSarif(report, version) {
28
+ const rules = new Map();
29
+ const results = [];
30
+ for (const check of report.results) {
31
+ for (const f of check.findings) {
32
+ // Success/info summary rows aren't actionable alerts.
33
+ if (f.severity === "success")
34
+ continue;
35
+ if (f.severity === "info" && !f.file)
36
+ continue;
37
+ if (!rules.has(f.rule)) {
38
+ rules.set(f.rule, {
39
+ id: f.rule,
40
+ name: ruleName(f.rule),
41
+ shortDescription: { text: f.rule },
42
+ defaultConfiguration: { level: toLevel(f.severity) },
43
+ helpUri: "https://github.com/formalness/shipready#readme",
44
+ });
45
+ }
46
+ results.push({
47
+ ruleId: f.rule,
48
+ level: toLevel(f.severity),
49
+ message: { text: f.message },
50
+ locations: [
51
+ {
52
+ physicalLocation: {
53
+ artifactLocation: {
54
+ // SARIF requires a location; findings without a file anchor
55
+ // to the repo root via package.json-ish convention.
56
+ uri: f.file ?? ".",
57
+ uriBaseId: "%SRCROOT%",
58
+ },
59
+ ...(f.line && f.line > 0 ? { region: { startLine: f.line } } : {}),
60
+ },
61
+ },
62
+ ],
63
+ partialFingerprints: { shipready: fingerprint(f) },
64
+ });
65
+ }
66
+ }
67
+ const log = {
68
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
69
+ version: "2.1.0",
70
+ runs: [
71
+ {
72
+ tool: {
73
+ driver: {
74
+ name: "shipready",
75
+ version,
76
+ informationUri: "https://github.com/formalness/shipready",
77
+ rules: [...rules.values()],
78
+ },
79
+ },
80
+ results,
81
+ },
82
+ ],
83
+ };
84
+ return JSON.stringify(log, null, 2);
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipready",
3
- "version": "1.3.2",
3
+ "version": "1.5.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",