@getfootgun/footgun 0.2.1 → 0.2.3

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
@@ -14,6 +14,8 @@ npx @getfootgun/footgun scan [path] [--json] [--fail-on breaks|will-break] [--ke
14
14
  - `--fail-on breaks` exits 1 when something breaks now. `--fail-on will-break` also exits 1 for anything with a known break date or version. Use either in CI.
15
15
  - Exit code 2 means the rules could not be downloaded and there is no cached copy. Exit code 3 means the scan could not run.
16
16
 
17
+ After a scan, `scan` prints a Markdown badge for your README, dated the day it ran. It reads "no known API traps" only when the scan found nothing at all, and "scanned" otherwise. `--json` puts the same Markdown in a `badge` field.
18
+
17
19
  Requires Node 20 or later.
18
20
 
19
21
  `scan` sends one anonymous usage count (that it ran, and how many traps matched at each severity, never paths, package names or code). Set `FOOTGUN_SHARE=false` to turn it off.
package/bin/footgun.mjs CHANGED
@@ -7,6 +7,7 @@ import { loadPack, NoRulesError, pingCounts, sendPing } from "../lib/pack.mjs";
7
7
  import { scanProject } from "../lib/scan.mjs";
8
8
  import { runContext } from "../lib/context.mjs";
9
9
  import { textReport, jsonReport, failed, FAIL_ON } from "../lib/report.mjs";
10
+ import { badgeMarkdown } from "../lib/badge.mjs";
10
11
  const BASE = process.env.FOOTGUN_URL ?? "https://getfootgun.com";
11
12
  const KEY = process.env.FOOTGUN_API_KEY;
12
13
  const [a, ...rest] = process.argv.slice(2);
@@ -77,11 +78,13 @@ async function scan(args) {
77
78
  // a matcher that cannot start rejects here and exits 3 in main, so no clean report is printed
78
79
  const result = await scanProject(resolve(o.path), pack.rules);
79
80
  const skippedRules = [...pack.skippedRules, ...result.skippedRules];
81
+ const badge = badgeMarkdown(result.findings, { base: BASE, date: new Date().toISOString().slice(0, 10) });
80
82
  if (o.json) {
81
- console.log(jsonReport({ findings: result.findings, skippedRules, fromCache, version: pack.version }));
83
+ console.log(jsonReport({ findings: result.findings, skippedRules, fromCache, version: pack.version, badge }));
82
84
  } else {
83
85
  for (const s of skippedRules) console.error(clean(`Skipped rule ${s.id}: ${s.why}`));
84
86
  console.log(textReport(result.findings));
87
+ console.log(`\nREADME badge for this scan:\n${badge}`);
85
88
  }
86
89
  if (failed(result.findings, o.failOn)) process.exitCode = 1;
87
90
  await sendPing({ url: BASE, kind: "scan", counts: pingCounts(uniqueTraps(result.findings)) });
package/lib/badge.mjs ADDED
@@ -0,0 +1,13 @@
1
+ // The README badge `footgun scan` suggests. It claims only what this scan showed, on the day it ran:
2
+ // "no known API traps" when nothing at all was found (check-yourself items included), otherwise just "scanned".
3
+
4
+ /** "clean" only when the scan found nothing, not even a check-yourself item. */
5
+ export function badgeStatus(findings) {
6
+ return findings.length === 0 ? "clean" : "scanned";
7
+ }
8
+
9
+ export function badgeMarkdown(findings, { base, date }) {
10
+ const status = badgeStatus(findings);
11
+ const alt = status === "clean" ? `No known API traps on ${date} · Footgun` : `Scanned by Footgun on ${date}`;
12
+ return `[![${alt}](${base}/badge.svg?s=${status}&d=${date})](${base}/scan)`;
13
+ }
package/lib/report.mjs CHANGED
@@ -44,6 +44,6 @@ export function textReport(findings) {
44
44
  return out.join("\n");
45
45
  }
46
46
 
47
- export function jsonReport({ findings, skippedRules, fromCache, version }) {
48
- return JSON.stringify({ version, fromCache, findings, skippedRules }, null, 2);
47
+ export function jsonReport({ findings, skippedRules, fromCache, version, badge }) {
48
+ return JSON.stringify({ version, fromCache, findings, skippedRules, badge }, null, 2);
49
49
  }
package/lib/rules.mjs CHANGED
@@ -128,6 +128,13 @@ function isSafe(rule) {
128
128
  return v;
129
129
  }
130
130
 
131
+ // A line whose trimmed start is a comment marker is not matched (`#!` shebangs fall under `#`).
132
+ // Block comments are not parsed: only lines starting with `/*` or `*` are skipped.
133
+ const COMMENT_START = /^(?:\/\/|#|\/\*|\*|<!--|--)/;
134
+ // A rule whose pattern itself targets comment syntax (contains `<!--`, or starts with a comment marker) is not subject to the skip.
135
+ // For ordinary rules, `#[attr]`, `#include` and Markdown `*` bullets are skipped like any other comment-looking line.
136
+ const COMMENT_RULE = /<!--|^\^?(?:\\s\*|\(\?:|\()*(?:--|#|\/\/|\/\\\*|\\\*)/;
137
+
131
138
  export function matchRule(rule, relPath, text) {
132
139
  if (!isSafe(rule)) return [];
133
140
  if (!rule.files.some((g) => globMatch(g, relPath))) return [];
@@ -139,10 +146,12 @@ export function matchRule(rule, relPath, text) {
139
146
  return [];
140
147
  }
141
148
  if (unless && unless.test(text)) return [];
149
+ const skipComments = !COMMENT_RULE.test(rule.pattern);
142
150
  const hits = [];
143
151
  const lines = text.split(/\r?\n/);
144
152
  for (let i = 0; i < lines.length; i++) {
145
153
  if (lines[i].length > MAX_LINE) continue;
154
+ if (skipComments && COMMENT_START.test(lines[i].trimStart())) continue;
146
155
  if (re.test(lines[i])) hits.push(i + 1);
147
156
  }
148
157
  return hits;
package/lib/scan.mjs CHANGED
@@ -303,16 +303,16 @@ export async function scanProject(root, rules) {
303
303
  : [];
304
304
 
305
305
  const findings = [];
306
- const seen = new Set();
306
+ // one finding per trap per file, at its lowest matching line
307
+ const first = new Map();
307
308
  const hitRules = new Set();
308
309
  for (const { rel, d, line } of hits) {
309
310
  const ri = detects[d].ri;
310
- const key = `${ri}\0${rel}\0${line}`;
311
- if (seen.has(key)) continue;
312
- seen.add(key);
311
+ const key = `${ri}\0${rel}`;
313
312
  hitRules.add(ri);
314
- findings.push(finding(good[ri], rel, line, false));
313
+ if (!first.has(key) || line < first.get(key).line) first.set(key, { ri, rel, line });
315
314
  }
315
+ for (const { ri, rel, line } of first.values()) findings.push(finding(good[ri], rel, line, false));
316
316
  findings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
317
317
  // a manual finding and the applicable list both need a ref narrower than "any version": a broad ref
318
318
  // only shows the package is present, which is enough to run detect rules but not to say the trap applies
package/package.json CHANGED
@@ -1 +1 @@
1
- { "name": "@getfootgun/footgun", "version": "0.2.1", "description": "Ask Footgun why a third-party API, platform, or AI tool just broke.", "bin": { "footgun": "bin/footgun.mjs" }, "scripts": { "prepublishOnly": "node ../scripts/check-publish.mjs" }, "type": "module", "license": "MIT", "files": ["bin", "lib", "README.md"], "engines": { "node": ">=20" } }
1
+ { "name": "@getfootgun/footgun", "version": "0.2.3", "description": "Ask Footgun why a third-party API, platform, or AI tool just broke.", "bin": { "footgun": "bin/footgun.mjs" }, "scripts": { "prepublishOnly": "node ../scripts/check-publish.mjs" }, "type": "module", "license": "MIT", "files": ["bin", "lib", "README.md"], "engines": { "node": ">=20" } }