itworksbut 0.5.0 → 0.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.
package/README.md CHANGED
@@ -6,7 +6,19 @@ It focuses on common "it works, but..." risks often found in AI-generated or rus
6
6
 
7
7
  For every finding, ItWorksBut gives you a copy-ready fix prompt you can paste into your coding agent. It does not just say what is wrong; it tells your AI exactly what to inspect, what to change, and what not to leak.
8
8
 
9
- It only reads files and reports findings. It does not call external APIs, does not send telemetry, and does not modify the scanned project unless you explicitly ask it to write `todo.md` with `--todo`.
9
+ It mostly reads files and reports findings. It does not send telemetry. The outdated-package check may invoke your local package manager, and the CLI only writes files when you explicitly ask for `todo.md` with `--todo` or `report.md` with `--report`.
10
+
11
+ ## Table of Contents
12
+
13
+ - [Installation](#installation)
14
+ - [Quick Start](#quick-start)
15
+ - [Options](#options)
16
+ - [Terminal Experience](#terminal-experience)
17
+ - [GitHub Actions](#github-actions)
18
+ - [Configuration](#configuration)
19
+ - [Example Output](#example-output)
20
+ - [What It Detects](#what-it-detects)
21
+ - [What It Does Not Guarantee](#what-it-does-not-guarantee)
10
22
 
11
23
  ## Installation
12
24
 
@@ -41,6 +53,7 @@ itworksbut scan --fail-on high
41
53
  itworksbut scan --json
42
54
  itworksbut scan --sarif > itworksbut.sarif
43
55
  itworksbut scan --todo
56
+ itworksbut scan --report
44
57
  itworksbut scan --config itworksbut.config.json
45
58
  itworksbut scan --verbose
46
59
  itworksbut --version
@@ -58,6 +71,7 @@ itworksbut scan [options]
58
71
  - `--json`: Print machine-readable JSON only. No banner, colors, spinner, table, or extra text.
59
72
  - `--sarif`: Print SARIF JSON for GitHub Code Scanning. No banner, colors, spinner, table, or extra text.
60
73
  - `--todo`: Write an AI-ready `todo.md` into the scanned project with prioritized findings, fix prompts, and acceptance criteria.
74
+ - `--report`: Write a Markdown `report.md` into the current working directory.
61
75
  - `--verbose`: Include scanner warnings and extra metadata in console output.
62
76
  - `--quiet`: Print only the summary.
63
77
  - `--no-color`: Disable colored output.
@@ -97,6 +111,14 @@ itworksbut scan --todo
97
111
 
98
112
  This writes `todo.md` to the scanned project. The file is ordered by severity and includes agent rules, exact fix prompts, locations, recommendations, and final verification checkboxes.
99
113
 
114
+ To create a Markdown scan report:
115
+
116
+ ```sh
117
+ itworksbut scan --report
118
+ ```
119
+
120
+ This writes `report.md` to the current working directory with check statuses, summaries, details, and recommendations.
121
+
100
122
  ## GitHub Actions
101
123
 
102
124
  ```yaml
@@ -166,38 +188,10 @@ release/**
166
188
  ## Example Output
167
189
 
168
190
  ![screenshot of an example output](/assets/medium_problems.webp)
169
- ✖ CRITICAL It works, but your .env is tracked.
170
- ✔ Check: env.env-file-tracked
171
- 📁 File: .env
172
- 🤔 Why: .env appears to be tracked by git. Secrets may be exposed.
173
- 🤖 prompt: You are a senior security engineer working in this repository. Fix the ItWorksBut finding env.env-file-tracked at .env. Treat this as a concrete finding. Problem: .env appears to be tracked by git. Secrets may be exposed. Required change: Remove tracked env files from git, add safe examples such as .env.example, and make sure any exposed credentials are treated as compromised. Do not print, log, or preserve raw secret values; use placeholders only. Keep existing behavior intact where possible, add or update focused tests when useful, and do not silence the check unless the underlying risk is actually fixed.
174
-
175
- ▲ HIGH It works, but your SQL query is one template string away from pain.
176
- ✔ Check: database.raw-sql-interpolation
177
- 📁 File: src/db.js:12
178
- 🤔 Why: Possible SQL injection risk: raw SQL appears to be built with template string interpolation.
179
- 🤖 prompt: You are a senior security engineer working in this repository. Fix the ItWorksBut finding database.raw-sql-interpolation at src/db.js:12. This finding is heuristic, so inspect the code first and only change behavior when the risk is real. Problem: Possible SQL injection risk: raw SQL appears to be built with template string interpolation. Required change: Replace SQL string interpolation or concatenation with parameterized queries, prepared statements, or a safe ORM query builder. Keep existing behavior intact where possible, add or update focused tests when useful, and do not silence the check unless the underlying risk is actually fixed.
180
-
181
- SUMMARY
182
-
183
- - ship status: DO NOT SHIP
184
- - Fix the red stuff before production.
185
- - total findings: 2
186
- - critical: 1
187
- - high: 1
188
- - medium: 0
189
- - low: 0
190
- - info: 0
191
- - fail-on: high
192
- - exit decision: 1
193
-
194
- ```
195
-
196
- Secret-like findings never print the full secret value. Findings report the file, line, and secret type where possible.
197
191
 
198
192
  ## What It Detects
199
193
 
200
- The baseline includes 50 modular checks:
194
+ The baseline includes 51 modular checks:
201
195
 
202
196
  - `git.gitignore-missing`
203
197
  - `git.gitignore-incomplete`
@@ -211,6 +205,7 @@ The baseline includes 50 modular checks:
211
205
  - `dependencies.multiple-lockfiles`
212
206
  - `dependencies.install-scripts-risk`
213
207
  - `dependencies.audit-script-missing`
208
+ - `dependencies.outdated-packages`
214
209
  - `package.scripts-missing`
215
210
  - `ci.no-ci-config`
216
211
  - `ci.npm-install-instead-of-npm-ci`
@@ -257,4 +252,3 @@ Each check is a plain ESM module with an `id`, metadata, and async `run(context)
257
252
  ItWorksBut is a static heuristic scanner, not a pentest, SAST replacement, dependency vulnerability database, or runtime security monitor. Findings intentionally use wording such as "possible", "potential", and "appears to" when a check is heuristic.
258
253
 
259
254
  Use it as a CI guardrail for common project hygiene and security mistakes. For production systems, combine it with code review, tests, dependency scanning, secrets scanning, threat modeling, and focused security assessment.
260
- ```
package/bin/itworksbut.js CHANGED
@@ -10,6 +10,7 @@ import { reportConsole } from '../src/reporters/consoleReporter.js';
10
10
  import { reportJson } from '../src/reporters/jsonReporter.js';
11
11
  import { reportSarif } from '../src/reporters/sarifReporter.js';
12
12
  import { writeTodoReport } from '../src/reporters/todoReporter.js';
13
+ import { writeMarkdownReport } from '../src/reporters/markdownReport.js';
13
14
 
14
15
  async function main() {
15
16
  const args = parseArgs(process.argv.slice(2));
@@ -59,6 +60,14 @@ async function main() {
59
60
  reportConsole(result, args);
60
61
  }
61
62
 
63
+ if (args.report) {
64
+ const report = await writeMarkdownReport(result);
65
+ if (!args.json && !args.sarif) {
66
+ const verb = report.overwritten ? 'Overwrote' : 'Wrote';
67
+ process.stdout.write(`${verb} scan report: ${report.filePath}\n`);
68
+ }
69
+ }
70
+
62
71
  return getExitCode(result.findings, result.config.failOn);
63
72
  }
64
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "itworksbut",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Static CI checks for common security, repo, dependency, build, and deployment risks in JavaScript vibe coding projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,297 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { detectOutdatedPackageManager, getOutdatedCommand } from "../../utils/packageManager.js";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+ const CHECK_ID = "dependencies.outdated-packages";
7
+ const CHECK_TITLE = "Outdated packages";
8
+ const COMMAND_TIMEOUT_MS = 30_000;
9
+ const COMMAND_MAX_BUFFER = 10 * 1024 * 1024;
10
+
11
+ export default {
12
+ id: CHECK_ID,
13
+ title: CHECK_TITLE,
14
+ category: "dependencies",
15
+ severity: "info",
16
+ tags: ["dependencies", "maintenance"],
17
+ run: async (context) => {
18
+ const result = await runOutdatedPackagesCheck(context);
19
+ return { findings: [], result };
20
+ }
21
+ };
22
+
23
+ export async function runOutdatedPackagesCheck(context, options = {}) {
24
+ const detected = detectOutdatedPackageManager({
25
+ packageJson: context.packageJson,
26
+ files: context.allFiles
27
+ });
28
+
29
+ if (detected.status === "skip") {
30
+ return checkResult({
31
+ status: "skip",
32
+ summary: detected.summary,
33
+ metadata: { reason: "missing-package-json" }
34
+ });
35
+ }
36
+
37
+ const commandResult = await runOutdatedCommand(detected.manager, context.rootPath, options);
38
+ if (isMissingCommand(commandResult)) {
39
+ return checkResult({
40
+ status: "fail",
41
+ summary: `${detected.manager} is not installed or could not be found.`,
42
+ details: [
43
+ {
44
+ message: `${detected.manager} is not installed or could not be found.`,
45
+ command: renderCommand(commandResult),
46
+ exitCode: commandResult.exitCode
47
+ }
48
+ ],
49
+ metadata: { packageManager: detected.manager }
50
+ });
51
+ }
52
+
53
+ const parsed = parseOutdatedOutput(detected.manager, commandResult.stdout, context.packageJson);
54
+ if (!parsed.ok) {
55
+ if (!hasCommandFailure(commandResult) && isEmptyOutput(commandResult.stdout)) {
56
+ return checkResult({
57
+ status: "pass",
58
+ summary: "all dependencies are up to date",
59
+ details: [],
60
+ metadata: { packageManager: detected.manager }
61
+ });
62
+ }
63
+
64
+ return checkResult({
65
+ status: "fail",
66
+ summary: `${detected.manager} outdated returned output that could not be parsed.`,
67
+ details: [
68
+ {
69
+ message: parsed.error,
70
+ command: renderCommand(commandResult),
71
+ exitCode: commandResult.exitCode,
72
+ stderr: trimText(commandResult.stderr)
73
+ }
74
+ ],
75
+ metadata: { packageManager: detected.manager }
76
+ });
77
+ }
78
+
79
+ if ((hasCommandFailure(commandResult) || hasEmptyExitOneFailure(commandResult)) && parsed.packages.length === 0) {
80
+ return checkResult({
81
+ status: "fail",
82
+ summary: `${detected.manager} outdated failed.`,
83
+ details: [
84
+ {
85
+ message: trimText(commandResult.stderr) || "Command exited with code " + commandResult.exitCode + ".",
86
+ command: renderCommand(commandResult),
87
+ exitCode: commandResult.exitCode
88
+ }
89
+ ],
90
+ metadata: { packageManager: detected.manager }
91
+ });
92
+ }
93
+
94
+ if (parsed.packages.length === 0) {
95
+ return checkResult({
96
+ status: "pass",
97
+ summary: "all dependencies are up to date",
98
+ details: [],
99
+ metadata: { packageManager: detected.manager }
100
+ });
101
+ }
102
+
103
+ return checkResult({
104
+ status: "warn",
105
+ summary: `${parsed.packages.length} ${parsed.packages.length === 1 ? "package" : "packages"} outdated`,
106
+ details: parsed.packages,
107
+ metadata: { packageManager: detected.manager }
108
+ });
109
+ }
110
+
111
+ export async function runOutdatedCommand(manager, rootPath, options = {}) {
112
+ const runCommand = options.runCommand || execCommand;
113
+ const { command, args } = getOutdatedCommand(manager);
114
+ return await runCommand(command, args, { cwd: rootPath });
115
+ }
116
+
117
+ export function parseOutdatedOutput(manager, stdout, packageJson = {}) {
118
+ const output = String(stdout || "").trim();
119
+ if (!output) return { ok: true, packages: [] };
120
+
121
+ try {
122
+ if (manager === "yarn") {
123
+ return { ok: true, packages: parseYarnOutput(output, packageJson) };
124
+ }
125
+
126
+ const parsed = JSON.parse(output);
127
+ return { ok: true, packages: normalizeOutdatedData(parsed, packageJson) };
128
+ } catch (error) {
129
+ return {
130
+ ok: false,
131
+ packages: [],
132
+ error: error instanceof Error ? error.message : String(error)
133
+ };
134
+ }
135
+ }
136
+
137
+ export function normalizeOutdatedData(data, packageJson = {}) {
138
+ if (!data || typeof data !== "object") return [];
139
+
140
+ if (Array.isArray(data)) {
141
+ return data
142
+ .map((entry) => normalizePackageEntry(entry.name || entry.package, entry, packageJson))
143
+ .filter(Boolean);
144
+ }
145
+
146
+ return Object.entries(data)
147
+ .map(([name, entry]) => normalizePackageEntry(name, entry, packageJson))
148
+ .filter(Boolean);
149
+ }
150
+
151
+ async function execCommand(command, args, options) {
152
+ try {
153
+ const result = await execFileAsync(command, args, {
154
+ cwd: options.cwd,
155
+ timeout: COMMAND_TIMEOUT_MS,
156
+ maxBuffer: COMMAND_MAX_BUFFER
157
+ });
158
+ return {
159
+ command,
160
+ args,
161
+ stdout: result.stdout || "",
162
+ stderr: result.stderr || "",
163
+ exitCode: 0
164
+ };
165
+ } catch (error) {
166
+ return {
167
+ command,
168
+ args,
169
+ stdout: error?.stdout || "",
170
+ stderr: error?.stderr || error?.message || "",
171
+ exitCode: error?.code ?? null,
172
+ signal: error?.signal,
173
+ error
174
+ };
175
+ }
176
+ }
177
+
178
+ function parseYarnOutput(output, packageJson) {
179
+ const directJson = tryJson(output);
180
+ if (directJson.ok) {
181
+ if (directJson.value?.type === "table") return parseYarnTable(directJson.value.data, packageJson);
182
+ return normalizeOutdatedData(directJson.value, packageJson);
183
+ }
184
+
185
+ const records = output
186
+ .split(/\r?\n/)
187
+ .map((line) => line.trim())
188
+ .filter(Boolean)
189
+ .map((line) => tryJson(line))
190
+ .filter((record) => record.ok)
191
+ .map((record) => record.value);
192
+
193
+ const tableRecord = records.find((record) => record?.type === "table" && record.data);
194
+ if (tableRecord) return parseYarnTable(tableRecord.data, packageJson);
195
+
196
+ const packageRecords = records
197
+ .filter((record) => record?.data && typeof record.data === "object")
198
+ .map((record) => record.data);
199
+ if (packageRecords.length > 0) return normalizeOutdatedData(packageRecords, packageJson);
200
+
201
+ throw new Error("No parseable Yarn outdated JSON records were found.");
202
+ }
203
+
204
+ function parseYarnTable(data, packageJson) {
205
+ const head = Array.isArray(data?.head) ? data.head.map((value) => String(value).toLowerCase()) : [];
206
+ const body = Array.isArray(data?.body) ? data.body : [];
207
+
208
+ return body.map((row) => {
209
+ const entry = {
210
+ name: row[indexOf(head, "package", 0)],
211
+ current: row[indexOf(head, "current", 1)],
212
+ wanted: row[indexOf(head, "wanted", 2)],
213
+ latest: row[indexOf(head, "latest", 3)],
214
+ type: row[indexOf(head, "package type", 4)]
215
+ };
216
+ return normalizePackageEntry(entry.name, entry, packageJson);
217
+ }).filter(Boolean);
218
+ }
219
+
220
+ function normalizePackageEntry(name, entry, packageJson) {
221
+ if (!name || !entry || typeof entry !== "object") return null;
222
+
223
+ const current = stringValue(entry.current ?? entry.installed ?? entry.version);
224
+ const wanted = stringValue(entry.wanted ?? entry.latest);
225
+ const latest = stringValue(entry.latest ?? entry.wanted);
226
+
227
+ if (!current && !wanted && !latest) return null;
228
+
229
+ return {
230
+ name: String(name),
231
+ current: current || "unknown",
232
+ wanted: wanted || "unknown",
233
+ latest: latest || "unknown",
234
+ type: stringValue(entry.type ?? entry.dependencyType ?? entry.packageType) || inferDependencyType(name, packageJson)
235
+ };
236
+ }
237
+
238
+ function inferDependencyType(name, packageJson = {}) {
239
+ if (Object.hasOwn(packageJson.dependencies || {}, name)) return "dependencies";
240
+ if (Object.hasOwn(packageJson.devDependencies || {}, name)) return "devDependencies";
241
+ if (Object.hasOwn(packageJson.peerDependencies || {}, name)) return "peerDependencies";
242
+ if (Object.hasOwn(packageJson.optionalDependencies || {}, name)) return "optionalDependencies";
243
+ return "dependencies";
244
+ }
245
+
246
+ function checkResult(result) {
247
+ return {
248
+ id: CHECK_ID,
249
+ title: CHECK_TITLE,
250
+ category: "dependencies",
251
+ details: [],
252
+ ...result
253
+ };
254
+ }
255
+
256
+ function hasCommandFailure(result) {
257
+ return result.exitCode !== 0 && result.exitCode !== 1;
258
+ }
259
+
260
+ function hasEmptyExitOneFailure(result) {
261
+ return result.exitCode === 1 && isEmptyOutput(result.stdout) && !isEmptyOutput(result.stderr);
262
+ }
263
+
264
+ function isMissingCommand(result) {
265
+ return result.exitCode === "ENOENT" || result.error?.code === "ENOENT";
266
+ }
267
+
268
+ function isEmptyOutput(value) {
269
+ return String(value || "").trim() === "";
270
+ }
271
+
272
+ function renderCommand(result) {
273
+ return [result.command, ...(result.args || [])].filter(Boolean).join(" ");
274
+ }
275
+
276
+ function trimText(value) {
277
+ const normalized = String(value || "").trim();
278
+ return normalized.length > 1000 ? `${normalized.slice(0, 1000)}...` : normalized;
279
+ }
280
+
281
+ function tryJson(value) {
282
+ try {
283
+ return { ok: true, value: JSON.parse(value) };
284
+ } catch {
285
+ return { ok: false, value: null };
286
+ }
287
+ }
288
+
289
+ function indexOf(head, name, fallback) {
290
+ const index = head.indexOf(name);
291
+ return index === -1 ? fallback : index;
292
+ }
293
+
294
+ function stringValue(value) {
295
+ if (value === undefined || value === null || value === "") return "";
296
+ return String(value);
297
+ }
@@ -10,6 +10,7 @@ import lockfileMissing from "./dependencies/lockfile-missing.js";
10
10
  import multipleLockfiles from "./dependencies/multiple-lockfiles.js";
11
11
  import installScriptsRisk from "./dependencies/install-scripts-risk.js";
12
12
  import auditScriptMissing from "./dependencies/audit-script-missing.js";
13
+ import outdatedPackages from "./dependencies/outdated-packages.js";
13
14
  import packageScriptsMissing from "./package/scripts-missing.js";
14
15
  import noCiConfig from "./ci/no-ci-config.js";
15
16
  import npmInstallInsteadOfNpmCi from "./ci/npm-install-instead-of-npm-ci.js";
@@ -62,6 +63,7 @@ export default [
62
63
  multipleLockfiles,
63
64
  installScriptsRisk,
64
65
  auditScriptMissing,
66
+ outdatedPackages,
65
67
  packageScriptsMissing,
66
68
  noCiConfig,
67
69
  npmInstallInsteadOfNpmCi,
package/src/cli/output.js CHANGED
@@ -14,6 +14,7 @@ Options:
14
14
  --json Print machine-readable JSON.
15
15
  --sarif Print SARIF for GitHub Code Scanning.
16
16
  --todo Write an AI-ready todo.md to the scanned project.
17
+ --report Write a Markdown scan report.md to the current directory.
17
18
  --no-color Disable color styling.
18
19
  --no-banner Disable the intro banner.
19
20
  --quiet Print only the summary.
@@ -1,5 +1,5 @@
1
1
  const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path"]);
2
- const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
2
+ const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--report", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
3
3
 
4
4
  export function parseArgs(argv) {
5
5
  const args = {
@@ -10,6 +10,7 @@ export function parseArgs(argv) {
10
10
  json: false,
11
11
  sarif: false,
12
12
  todo: false,
13
+ report: false,
13
14
  verbose: false,
14
15
  noColor: false,
15
16
  noBanner: false,
@@ -48,6 +49,7 @@ export function parseArgs(argv) {
48
49
  if (token === "--json") args.json = true;
49
50
  if (token === "--sarif") args.sarif = true;
50
51
  if (token === "--todo") args.todo = true;
52
+ if (token === "--report") args.report = true;
51
53
  if (token === "--verbose") args.verbose = true;
52
54
  if (token === "--no-color") args.noColor = true;
53
55
  if (token === "--no-banner") args.noBanner = true;
@@ -0,0 +1,53 @@
1
+ export const CHECK_STATUSES = ["pass", "warn", "fail", "skip"];
2
+
3
+ export function normalizeCheckResult(check, value = {}, findings = []) {
4
+ const status = normalizeStatus(value.status || deriveStatusFromFindings(findings));
5
+ const details = Array.isArray(value.details) ? value.details : detailsFromFindings(findings);
6
+
7
+ return {
8
+ id: value.id || check.id,
9
+ title: value.title || check.title,
10
+ category: value.category || check.category,
11
+ status,
12
+ summary: value.summary || defaultSummary(status, findings),
13
+ details,
14
+ metadata: value.metadata || undefined
15
+ };
16
+ }
17
+
18
+ export function deriveStatusFromFindings(findings = []) {
19
+ if (!findings.length) return "pass";
20
+ if (findings.some((finding) => finding.severity === "critical" || finding.severity === "high")) return "fail";
21
+ return "warn";
22
+ }
23
+
24
+ export function countByStatus(checks = []) {
25
+ return Object.fromEntries(
26
+ CHECK_STATUSES.map((status) => [status, checks.filter((check) => check.status === status).length])
27
+ );
28
+ }
29
+
30
+ function normalizeStatus(value) {
31
+ const normalized = String(value || "pass").toLowerCase();
32
+ return CHECK_STATUSES.includes(normalized) ? normalized : "fail";
33
+ }
34
+
35
+ function defaultSummary(status, findings) {
36
+ if (status === "pass") return "No issues found.";
37
+ if (status === "skip") return "Skipped.";
38
+ if (!findings.length) return status === "fail" ? "Check failed." : "Check needs review.";
39
+
40
+ const count = findings.length;
41
+ const noun = count === 1 ? "finding" : "findings";
42
+ return `${count} ${noun} reported.`;
43
+ }
44
+
45
+ function detailsFromFindings(findings) {
46
+ return findings.map((finding) => ({
47
+ message: finding.message,
48
+ file: finding.file,
49
+ line: finding.line,
50
+ severity: finding.severity,
51
+ recommendation: finding.recommendation
52
+ }));
53
+ }
@@ -1,5 +1,6 @@
1
1
  import checks from "../checks/index.js";
2
2
  import { createContext } from "./context.js";
3
+ import { normalizeCheckResult } from "./checkResults.js";
3
4
  import { normalizeFinding, severityRank } from "./findings.js";
4
5
  import { packageInfo } from "./packageInfo.js";
5
6
 
@@ -7,28 +8,54 @@ export async function scanProject(options = {}) {
7
8
  const startedAt = new Date();
8
9
  const context = await createContext(options);
9
10
  const findings = [];
11
+ const checkResults = [];
10
12
  const warnings = [];
11
13
 
12
14
  for (const check of checks) {
13
- if (context.config.checks[check.id] === false) continue;
15
+ if (context.config.checks[check.id] === false) {
16
+ checkResults.push(normalizeCheckResult(check, {
17
+ status: "skip",
18
+ summary: "Disabled by configuration."
19
+ }));
20
+ continue;
21
+ }
14
22
 
15
23
  try {
16
- const checkFindings = await check.run(context);
24
+ const rawResult = await check.run(context);
25
+ const checkFindings = Array.isArray(rawResult) ? rawResult : rawResult?.findings;
26
+ const explicitCheckResult = Array.isArray(rawResult) ? null : rawResult?.result || rawResult?.checkResult;
27
+
17
28
  if (!Array.isArray(checkFindings)) {
18
29
  warnings.push({
19
30
  checkId: check.id,
20
31
  message: "Check returned a non-array result and was ignored."
21
32
  });
33
+ checkResults.push(normalizeCheckResult(check, {
34
+ status: "fail",
35
+ summary: "Check returned an invalid result.",
36
+ details: [{ message: "Check returned a non-array result and was ignored." }]
37
+ }));
22
38
  continue;
23
39
  }
40
+
41
+ const normalizedFindings = [];
24
42
  for (const finding of checkFindings) {
25
- findings.push(normalizeFinding(check, finding));
43
+ const normalizedFinding = normalizeFinding(check, finding);
44
+ normalizedFindings.push(normalizedFinding);
45
+ findings.push(normalizedFinding);
26
46
  }
47
+ checkResults.push(normalizeCheckResult(check, explicitCheckResult || {}, normalizedFindings));
27
48
  } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
28
50
  warnings.push({
29
51
  checkId: check.id,
30
- message: error instanceof Error ? error.message : String(error)
52
+ message
31
53
  });
54
+ checkResults.push(normalizeCheckResult(check, {
55
+ status: "fail",
56
+ summary: message,
57
+ details: [{ message }]
58
+ }));
32
59
  }
33
60
  }
34
61
 
@@ -40,12 +67,14 @@ export async function scanProject(options = {}) {
40
67
 
41
68
  return {
42
69
  findings,
70
+ checks: checkResults,
43
71
  warnings,
44
72
  config: context.config,
45
73
  meta: {
46
74
  tool: "ItWorksBut",
47
75
  version: packageInfo.version,
48
76
  rootPath: context.rootPath,
77
+ packageName: context.packageJson?.name,
49
78
  packageManager: context.packageManager,
50
79
  gitAvailable: context.gitAvailable,
51
80
  filesScanned: context.allFiles.length,
@@ -12,15 +12,17 @@ import {
12
12
 
13
13
  export function reportConsole(result, options = {}) {
14
14
  const { findings, warnings, config, meta } = result;
15
+ const checks = result.checks || [];
15
16
  const counts = countBySeverity(findings);
16
17
  const colors = getChalk(options);
17
18
  const rich = isFancyOutputEnabled(options);
19
+ const hasReviewCheck = checks.some(check => check.status === 'warn' || check.status === 'fail');
18
20
 
19
21
  if (!options.quiet && !rich) {
20
22
  process.stdout.write(`${colors.bold('ItWorksBut receipts')}\n\n`);
21
23
  }
22
24
 
23
- if (!options.quiet && findings.length === 0) {
25
+ if (!options.quiet && findings.length === 0 && !hasReviewCheck) {
24
26
  process.stdout.write(
25
27
  `${colors.green ? colors.green('Suspiciously clean. No findings.') : 'Suspiciously clean. No findings.'}\n\n`,
26
28
  );
@@ -36,6 +38,10 @@ export function reportConsole(result, options = {}) {
36
38
  }
37
39
  }
38
40
 
41
+ if (!options.quiet) {
42
+ writeOutdatedPackagesCheck(checks, options);
43
+ }
44
+
39
45
  if (options.verbose && warnings.length > 0) {
40
46
  process.stdout.write('WARNINGS\n');
41
47
  for (const warning of warnings) {
@@ -55,6 +61,41 @@ export function reportConsole(result, options = {}) {
55
61
  }
56
62
  }
57
63
 
64
+ function writeOutdatedPackagesCheck(checks, options) {
65
+ const check = checks.find(candidate => candidate.id === 'dependencies.outdated-packages');
66
+ if (!check) return;
67
+
68
+ const colors = getChalk(options);
69
+ const title = colors.bold('Outdated packages');
70
+
71
+ if (check.status === 'pass') {
72
+ process.stdout.write(`${colors.green('✓')} ${title}: ${check.summary}\n\n`);
73
+ return;
74
+ }
75
+
76
+ if (check.status === 'skip') {
77
+ process.stdout.write(`- ${title}: ${check.summary}\n\n`);
78
+ return;
79
+ }
80
+
81
+ if (check.status === 'fail') {
82
+ process.stdout.write(`${colors.red('✖')} ${title}: ${check.summary}\n\n`);
83
+ return;
84
+ }
85
+
86
+ process.stdout.write(`${colors.yellow('⚠')} ${title}: ${check.summary}\n`);
87
+ const packages = check.details.filter(detail => detail?.name).slice(0, 10);
88
+ for (const detail of packages) {
89
+ process.stdout.write(
90
+ ` - ${detail.name}: ${detail.current} → ${detail.wanted} wanted, ${detail.latest} latest\n`,
91
+ );
92
+ }
93
+ if (check.details.length > packages.length) {
94
+ process.stdout.write(` - and ${check.details.length - packages.length} more\n`);
95
+ }
96
+ process.stdout.write('\n');
97
+ }
98
+
58
99
  function writeFinding(finding, options) {
59
100
  const colors = getChalk(options);
60
101
  const severity = formatSeverity(finding.severity, options);
@@ -1,4 +1,5 @@
1
1
  import { countBySeverity, getExitCode } from "../core/findings.js";
2
+ import { countByStatus } from "../core/checkResults.js";
2
3
 
3
4
  export function reportJson(result) {
4
5
  return {
@@ -8,9 +9,11 @@ export function reportJson(result) {
8
9
  summary: {
9
10
  total: result.findings.length,
10
11
  bySeverity: countBySeverity(result.findings),
12
+ byStatus: countByStatus(result.checks || []),
11
13
  failOn: result.config.failOn,
12
14
  exitCode: getExitCode(result.findings, result.config.failOn)
13
15
  },
16
+ checks: result.checks || [],
14
17
  findings: result.findings,
15
18
  warnings: result.warnings
16
19
  };
@@ -0,0 +1,203 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { countByStatus } from "../core/checkResults.js";
4
+
5
+ const ANSI_PATTERN = /\u001B\[[0-?]*[ -/]*[@-~]/g;
6
+
7
+ export async function writeMarkdownReport(result, options = {}) {
8
+ const filePath = options.filePath || path.join(options.directoryPath || process.cwd(), "report.md");
9
+ let overwritten = false;
10
+
11
+ try {
12
+ await fs.access(filePath);
13
+ overwritten = true;
14
+ } catch {
15
+ overwritten = false;
16
+ }
17
+
18
+ await fs.writeFile(filePath, reportMarkdown(result), "utf8");
19
+ return { filePath, overwritten };
20
+ }
21
+
22
+ export function reportMarkdown(result) {
23
+ const checks = result.checks || checksFromFindings(result.findings || []);
24
+ const counts = countByStatus(checks);
25
+ const projectName = result.meta?.packageName || path.basename(result.meta?.rootPath || "") || "unknown";
26
+ const generatedAt = formatTimestamp(result.meta?.completedAt || new Date());
27
+
28
+ return stripAnsi(`${[
29
+ "# ItWorksBut Scan Report",
30
+ "",
31
+ `Generated: ${generatedAt}`,
32
+ "",
33
+ `Project: ${projectName}`,
34
+ `Path: ${result.meta?.rootPath || "unknown"}`,
35
+ "",
36
+ "## Summary",
37
+ "",
38
+ "| Status | Count |",
39
+ "|---|---:|",
40
+ `| Pass | ${counts.pass} |`,
41
+ `| Warn | ${counts.warn} |`,
42
+ `| Fail | ${counts.fail} |`,
43
+ `| Skip | ${counts.skip} |`,
44
+ "",
45
+ "## Checks",
46
+ "",
47
+ renderChecks(checks),
48
+ renderScannerWarnings(result.warnings || []),
49
+ renderRecommendations(checks, result.findings || []),
50
+ ].filter((line) => line !== null && line !== undefined).join("\n")}\n`);
51
+ }
52
+
53
+ function renderChecks(checks) {
54
+ if (!checks.length) return "No checks were recorded.\n";
55
+
56
+ return checks.map(renderCheck).join("\n");
57
+ }
58
+
59
+ function renderCheck(check) {
60
+ return [
61
+ `### ${check.title || check.id}`,
62
+ "",
63
+ `Status: ${check.status || "unknown"}`,
64
+ "",
65
+ "Summary:",
66
+ check.summary || "No summary available.",
67
+ "",
68
+ "Details:",
69
+ renderDetails(check),
70
+ ""
71
+ ].join("\n");
72
+ }
73
+
74
+ function renderDetails(check) {
75
+ const details = Array.isArray(check.details) ? check.details : [];
76
+ if (!details.length) return "None.";
77
+
78
+ if (isOutdatedPackageCheck(check)) {
79
+ return renderOutdatedPackageTable(details);
80
+ }
81
+
82
+ return details.map(renderDetailBullet).join("\n");
83
+ }
84
+
85
+ function renderOutdatedPackageTable(details) {
86
+ const packageDetails = details.filter((detail) => detail?.name);
87
+ if (!packageDetails.length) return details.map(renderDetailBullet).join("\n");
88
+
89
+ return [
90
+ "| Package | Current | Wanted | Latest | Type |",
91
+ "|---|---:|---:|---:|---|",
92
+ ...packageDetails.map((detail) => (
93
+ `| ${escapeTable(detail.name)} | ${escapeTable(detail.current)} | ${escapeTable(detail.wanted)} | ${escapeTable(detail.latest)} | ${escapeTable(detail.type)} |`
94
+ ))
95
+ ].join("\n");
96
+ }
97
+
98
+ function renderDetailBullet(detail) {
99
+ if (typeof detail === "string") return `- ${detail}`;
100
+ if (!detail || typeof detail !== "object") return `- ${String(detail)}`;
101
+
102
+ if (detail.message) return `- ${detail.message}`;
103
+ return `- ${Object.entries(detail).map(([key, value]) => `${key}: ${String(value)}`).join(", ")}`;
104
+ }
105
+
106
+ function renderScannerWarnings(warnings) {
107
+ if (!warnings.length) return "";
108
+
109
+ return [
110
+ "## Scanner Warnings",
111
+ "",
112
+ ...warnings.map((warning) => `- ${warning.checkId}: ${warning.message}`),
113
+ ""
114
+ ].join("\n");
115
+ }
116
+
117
+ function renderRecommendations(checks, findings) {
118
+ const needsReview = checks.some((check) => check.status === "warn" || check.status === "fail");
119
+ if (!needsReview && findings.length === 0) return "";
120
+
121
+ const recommendations = [];
122
+ if (checks.some((check) => isOutdatedPackageCheck(check) && (check.status === "warn" || check.status === "fail"))) {
123
+ recommendations.push("Update outdated packages carefully.");
124
+ recommendations.push("Review major-version upgrades manually.");
125
+ recommendations.push("Run tests after dependency updates.");
126
+ }
127
+
128
+ for (const finding of findings) {
129
+ if (finding.recommendation) recommendations.push(finding.recommendation);
130
+ }
131
+
132
+ if (checks.some((check) => check.status === "fail")) {
133
+ recommendations.push("Review failed checks and scanner warnings before shipping.");
134
+ }
135
+ if (recommendations.length === 0) {
136
+ recommendations.push("Review warning and failure details before shipping.");
137
+ }
138
+
139
+ return [
140
+ "## Recommendations",
141
+ "",
142
+ ...unique(recommendations).map((recommendation) => `- ${recommendation}`),
143
+ ""
144
+ ].join("\n");
145
+ }
146
+
147
+ function checksFromFindings(findings) {
148
+ const byCheck = new Map();
149
+ for (const finding of findings) {
150
+ const existing = byCheck.get(finding.checkId) || {
151
+ id: finding.checkId,
152
+ title: finding.title,
153
+ category: finding.category,
154
+ status: finding.severity === "critical" || finding.severity === "high" ? "fail" : "warn",
155
+ summary: "Finding reported.",
156
+ details: []
157
+ };
158
+ existing.details.push({
159
+ message: finding.message,
160
+ file: finding.file,
161
+ line: finding.line,
162
+ severity: finding.severity
163
+ });
164
+ byCheck.set(finding.checkId, existing);
165
+ }
166
+ return [...byCheck.values()];
167
+ }
168
+
169
+ function isOutdatedPackageCheck(check) {
170
+ return check.id === "dependencies.outdated-packages" || check.title === "Outdated packages";
171
+ }
172
+
173
+ function formatTimestamp(value) {
174
+ const date = value instanceof Date ? value : new Date(value);
175
+ if (Number.isNaN(date.getTime())) return String(value);
176
+
177
+ const pad = (number) => String(number).padStart(2, "0");
178
+ return [
179
+ date.getFullYear(),
180
+ "-",
181
+ pad(date.getMonth() + 1),
182
+ "-",
183
+ pad(date.getDate()),
184
+ " ",
185
+ pad(date.getHours()),
186
+ ":",
187
+ pad(date.getMinutes()),
188
+ ":",
189
+ pad(date.getSeconds())
190
+ ].join("");
191
+ }
192
+
193
+ function escapeTable(value) {
194
+ return stripAnsi(String(value ?? "unknown")).replace(/\|/g, "\\|");
195
+ }
196
+
197
+ function stripAnsi(value) {
198
+ return String(value).replace(ANSI_PATTERN, "");
199
+ }
200
+
201
+ function unique(values) {
202
+ return [...new Set(values.filter(Boolean))];
203
+ }
@@ -0,0 +1,28 @@
1
+ export function detectOutdatedPackageManager({ packageJson, files = [] } = {}) {
2
+ if (!packageJson) {
3
+ return {
4
+ status: "skip",
5
+ manager: null,
6
+ summary: "skipped, no package.json found"
7
+ };
8
+ }
9
+
10
+ if (files.includes("pnpm-lock.yaml")) return packageManager("pnpm");
11
+ if (files.includes("yarn.lock")) return packageManager("yarn");
12
+ if (files.includes("package-lock.json")) return packageManager("npm");
13
+ return packageManager("npm");
14
+ }
15
+
16
+ export function getOutdatedCommand(manager) {
17
+ if (manager === "pnpm") return { command: "pnpm", args: ["outdated", "--json"] };
18
+ if (manager === "yarn") return { command: "yarn", args: ["outdated", "--json"] };
19
+ return { command: "npm", args: ["outdated", "--json"] };
20
+ }
21
+
22
+ function packageManager(manager) {
23
+ return {
24
+ status: "run",
25
+ manager,
26
+ ...getOutdatedCommand(manager)
27
+ };
28
+ }