@safets-org/cli 1.0.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/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/analyze.d.ts +3 -0
- package/dist/analyze.js +330 -0
- package/dist/detectors/index.d.ts +12 -0
- package/dist/detectors/index.js +785 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +141 -0
- package/dist/reporters/baseline.d.ts +8 -0
- package/dist/reporters/baseline.js +53 -0
- package/dist/reporters/index.d.ts +5 -0
- package/dist/reporters/index.js +208 -0
- package/dist/reporters/json.d.ts +71 -0
- package/dist/reporters/json.js +170 -0
- package/dist/utils/ast.d.ts +11 -0
- package/dist/utils/ast.js +66 -0
- package/dist/utils/colors.d.ts +8 -0
- package/dist/utils/colors.js +8 -0
- package/dist/utils/files.d.ts +9 -0
- package/dist/utils/files.js +123 -0
- package/dist/utils/types.d.ts +45 -0
- package/dist/utils/types.js +1 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import { analyze, loadProgramRobust } from "./analyze.js";
|
|
4
|
+
import { checkBaselineOptionsMismatch, isNew, loadBaseline, saveBaseline } from "./reporters/baseline.js";
|
|
5
|
+
import { printBaselineMismatchWarning, printDebt, printDoctor, printFix } from "./reporters/index.js";
|
|
6
|
+
import { printJsonReport } from "./reporters/json.js";
|
|
7
|
+
import { c } from "./utils/colors.js";
|
|
8
|
+
const COMMANDS = new Set(["doctor", "fix", "debt", "baseline"]);
|
|
9
|
+
const FLAGS = new Set(["--help", "--version", "--fail-on-new", "--baseline", "--include-tests", "--json"]);
|
|
10
|
+
function getVersion() {
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const packageJson = require("../package.json");
|
|
13
|
+
return packageJson.version ?? "0.0.0";
|
|
14
|
+
}
|
|
15
|
+
function printBanner(version, includeTests) {
|
|
16
|
+
console.log(c.bold(c.cyan(`\n SafeTS v${version}`)));
|
|
17
|
+
console.log(c.dim(" Finds common runtime crashes TypeScript can't detect\n"));
|
|
18
|
+
if (!includeTests) {
|
|
19
|
+
console.log(c.dim(" (test files excluded - use --include-tests to include them)\n"));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function printHelp(version) {
|
|
23
|
+
console.log(`SafeTS v${version}`);
|
|
24
|
+
console.log("Finds common runtime crashes TypeScript can't detect.\n");
|
|
25
|
+
console.log("Usage:");
|
|
26
|
+
console.log(" safets [command] [options]\n");
|
|
27
|
+
console.log("Commands:");
|
|
28
|
+
console.log(" doctor Analyze the project and print potential crashes (default)");
|
|
29
|
+
console.log(" fix Print manual fix suggestions");
|
|
30
|
+
console.log(" debt Show grouped crash debt, with baseline deltas when compatible");
|
|
31
|
+
console.log(" baseline Save the current crash snapshot to .safets-baseline.json\n");
|
|
32
|
+
console.log("Options:");
|
|
33
|
+
console.log(" --include-tests Include test files in the analysis");
|
|
34
|
+
console.log(" --baseline Save a baseline after `doctor` finishes");
|
|
35
|
+
console.log(" --fail-on-new Exit with code 1 when `doctor` finds crashes not in the baseline");
|
|
36
|
+
console.log(" --json Print machine-readable JSON instead of human output");
|
|
37
|
+
console.log(" --help Show this help message");
|
|
38
|
+
console.log(" --version Show the installed SafeTS version\n");
|
|
39
|
+
console.log("Exit codes:");
|
|
40
|
+
console.log(" 0 Successful run, including `doctor` with no blocking condition");
|
|
41
|
+
console.log(" 1 Invalid CLI usage, incompatible `--fail-on-new` baseline, or new crashes found\n");
|
|
42
|
+
console.log("Examples:");
|
|
43
|
+
console.log(" safets");
|
|
44
|
+
console.log(" safets doctor --fail-on-new");
|
|
45
|
+
console.log(" safets debt --include-tests");
|
|
46
|
+
console.log(" safets baseline");
|
|
47
|
+
}
|
|
48
|
+
const args = process.argv.slice(2);
|
|
49
|
+
const version = getVersion();
|
|
50
|
+
const root = process.cwd();
|
|
51
|
+
const unknownFlags = args.filter((arg) => arg.startsWith("-") && !FLAGS.has(arg));
|
|
52
|
+
if (unknownFlags.length > 0) {
|
|
53
|
+
console.error(c.red(`x Unknown option(s): ${unknownFlags.join(", ")}\n`));
|
|
54
|
+
printHelp(version);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
if (args.includes("--help") || args[0] === "help") {
|
|
58
|
+
printHelp(version);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
if (args.includes("--version") || args[0] === "version") {
|
|
62
|
+
console.log(version);
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
const nonFlagArgs = args.filter((arg) => !arg.startsWith("-"));
|
|
66
|
+
const command = nonFlagArgs[0] ?? "doctor";
|
|
67
|
+
if (!COMMANDS.has(command)) {
|
|
68
|
+
console.error(c.red(`x Unknown command: ${command}\n`));
|
|
69
|
+
printHelp(version);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
const failOnNew = args.includes("--fail-on-new");
|
|
73
|
+
const withBase = args.includes("--baseline");
|
|
74
|
+
const includeTests = args.includes("--include-tests");
|
|
75
|
+
const jsonOutput = args.includes("--json");
|
|
76
|
+
if (!jsonOutput) {
|
|
77
|
+
printBanner(version, includeTests);
|
|
78
|
+
}
|
|
79
|
+
if (command !== "doctor" && failOnNew) {
|
|
80
|
+
console.error(c.red("x `--fail-on-new` can only be used with `doctor`.\n"));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
if (command !== "doctor" && withBase) {
|
|
84
|
+
console.error(c.red("x `--baseline` can only be used with `doctor`.\n"));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const programResult = loadProgramRobust(root, includeTests);
|
|
88
|
+
const crashes = analyze(programResult);
|
|
89
|
+
const baseline = loadBaseline(root);
|
|
90
|
+
const baselineMismatch = baseline
|
|
91
|
+
? checkBaselineOptionsMismatch(baseline, includeTests)
|
|
92
|
+
: null;
|
|
93
|
+
if (jsonOutput) {
|
|
94
|
+
const comparableBase = baselineMismatch ? null : baseline;
|
|
95
|
+
const newCrashes = comparableBase
|
|
96
|
+
? crashes.filter((crash) => isNew(crash, comparableBase))
|
|
97
|
+
: crashes;
|
|
98
|
+
const failOnNewWillFail = command === "doctor" &&
|
|
99
|
+
failOnNew &&
|
|
100
|
+
(baselineMismatch !== null || newCrashes.length > 0);
|
|
101
|
+
const shouldSaveBaseline = command === "baseline" ||
|
|
102
|
+
(command === "doctor" && withBase && !failOnNewWillFail);
|
|
103
|
+
const savedBaseline = shouldSaveBaseline
|
|
104
|
+
? saveBaseline(crashes, root, programResult, version, { quiet: true })
|
|
105
|
+
: null;
|
|
106
|
+
printJsonReport({
|
|
107
|
+
command: command,
|
|
108
|
+
crashes,
|
|
109
|
+
root,
|
|
110
|
+
version,
|
|
111
|
+
includeTests,
|
|
112
|
+
failOnNew,
|
|
113
|
+
baseline,
|
|
114
|
+
baselineMismatch,
|
|
115
|
+
programResult,
|
|
116
|
+
savedBaseline,
|
|
117
|
+
});
|
|
118
|
+
if (failOnNewWillFail) {
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
switch (command) {
|
|
124
|
+
case "debt":
|
|
125
|
+
printBaselineMismatchWarning(baselineMismatch);
|
|
126
|
+
printDebt(crashes, baseline, baselineMismatch);
|
|
127
|
+
break;
|
|
128
|
+
case "fix":
|
|
129
|
+
printFix(crashes, root);
|
|
130
|
+
break;
|
|
131
|
+
case "baseline":
|
|
132
|
+
saveBaseline(crashes, root, programResult, version);
|
|
133
|
+
break;
|
|
134
|
+
case "doctor":
|
|
135
|
+
default:
|
|
136
|
+
printDoctor(crashes, root, failOnNew, baseline, programResult, baselineMismatch);
|
|
137
|
+
if (withBase) {
|
|
138
|
+
saveBaseline(crashes, root, programResult, version);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Baseline, CrashReport, ProgramResult } from "../utils/types.ts";
|
|
2
|
+
export declare const BASELINE_FILE = ".safets-baseline.json";
|
|
3
|
+
export declare function saveBaseline(crashes: CrashReport[], root: string, programResult: ProgramResult, version: string, options?: {
|
|
4
|
+
quiet?: boolean;
|
|
5
|
+
}): Baseline;
|
|
6
|
+
export declare function loadBaseline(root: string): Baseline | null;
|
|
7
|
+
export declare function isNew(crash: CrashReport, baseline: Baseline): boolean;
|
|
8
|
+
export declare function checkBaselineOptionsMismatch(baseline: Baseline, includeTests: boolean): string | null;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { c } from "../utils/colors.js";
|
|
4
|
+
import { normalizeFilePath, toProjectRelativePath } from "../utils/files.js";
|
|
5
|
+
export const BASELINE_FILE = ".safets-baseline.json";
|
|
6
|
+
export function saveBaseline(crashes, root, programResult, version, options = {}) {
|
|
7
|
+
const baseline = {
|
|
8
|
+
version,
|
|
9
|
+
date: new Date().toISOString(),
|
|
10
|
+
options: { includeTests: programResult.includeTests },
|
|
11
|
+
crashes: crashes.map((crash) => ({
|
|
12
|
+
file: toProjectRelativePath(root, crash.file),
|
|
13
|
+
line: crash.line,
|
|
14
|
+
expr: crash.expr,
|
|
15
|
+
pattern: crash.pattern,
|
|
16
|
+
})),
|
|
17
|
+
};
|
|
18
|
+
fs.writeFileSync(path.join(root, BASELINE_FILE), JSON.stringify(baseline, null, 2));
|
|
19
|
+
if (!options.quiet) {
|
|
20
|
+
console.log(c.green(`\nOK Baseline saved - ${crashes.length} known crash(es) recorded.`));
|
|
21
|
+
console.log(c.dim(` File: ${BASELINE_FILE}`));
|
|
22
|
+
console.log(c.dim(` Options: includeTests=${programResult.includeTests}`));
|
|
23
|
+
console.log(c.dim(" Commit this file to version control for CI to work correctly.\n"));
|
|
24
|
+
}
|
|
25
|
+
return baseline;
|
|
26
|
+
}
|
|
27
|
+
export function loadBaseline(root) {
|
|
28
|
+
try {
|
|
29
|
+
const filePath = path.join(root, BASELINE_FILE);
|
|
30
|
+
if (!fs.existsSync(filePath)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function isNew(crash, baseline) {
|
|
40
|
+
const absoluteCrashPath = normalizeFilePath(crash.file);
|
|
41
|
+
return !baseline.crashes.some((entry) => (entry.file === absoluteCrashPath || entry.file === toProjectRelativePath(process.cwd(), crash.file)) &&
|
|
42
|
+
entry.line === crash.line &&
|
|
43
|
+
entry.expr === crash.expr);
|
|
44
|
+
}
|
|
45
|
+
export function checkBaselineOptionsMismatch(baseline, includeTests) {
|
|
46
|
+
if (!baseline.options) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (baseline.options.includeTests !== includeTests) {
|
|
50
|
+
return `baseline was saved with includeTests=${baseline.options.includeTests} but current run uses includeTests=${includeTests}. Re-run 'safets baseline' first.`;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Baseline, CrashReport, ProgramResult } from "../utils/types.ts";
|
|
2
|
+
export declare function printDoctor(crashes: CrashReport[], root: string, failOnNew: boolean, base: Baseline | null, programResult: ProgramResult, baselineMismatch: string | null): void;
|
|
3
|
+
export declare function printDebt(crashes: CrashReport[], base: Baseline | null, baselineMismatch: string | null): void;
|
|
4
|
+
export declare function printBaselineMismatchWarning(baselineMismatch: string | null): void;
|
|
5
|
+
export declare function printFix(crashes: CrashReport[], root: string): void;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { isNew } from "./baseline.js";
|
|
3
|
+
import { c } from "../utils/colors.js";
|
|
4
|
+
function makeOptionalChainSuggestion(expr) {
|
|
5
|
+
const lastDot = expr.lastIndexOf(".");
|
|
6
|
+
if (lastDot === -1) {
|
|
7
|
+
return `${expr}?`;
|
|
8
|
+
}
|
|
9
|
+
return `${expr.slice(0, lastDot)}?.${expr.slice(lastDot + 1)}`;
|
|
10
|
+
}
|
|
11
|
+
export function printDoctor(crashes, root, failOnNew, base, programResult, baselineMismatch) {
|
|
12
|
+
const rel = (filePath) => path.relative(root, filePath);
|
|
13
|
+
const comparableBase = baselineMismatch ? null : base;
|
|
14
|
+
if (baselineMismatch) {
|
|
15
|
+
console.log(c.yellow(` ! Baseline mismatch: ${baselineMismatch}\n`));
|
|
16
|
+
if (failOnNew) {
|
|
17
|
+
console.log(c.red("x Refusing --fail-on-new against an incompatible baseline.\n"));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (programResult.warnings.length > 0) {
|
|
22
|
+
console.log(c.yellow(" ! Warnings:"));
|
|
23
|
+
programResult.warnings.forEach((warning) => console.log(c.dim(` ${warning}`)));
|
|
24
|
+
if (programResult.fallback) {
|
|
25
|
+
console.log(c.yellow(" ! Fallback mode - partial results only\n"));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
console.log();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (crashes.length === 0) {
|
|
32
|
+
console.log(c.green("OK No potential runtime crashes found.\n"));
|
|
33
|
+
if (programResult.fallback) {
|
|
34
|
+
console.log(c.dim(" Note: fallback mode may miss type-dependent crashes.\n"));
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const newCrashes = comparableBase
|
|
39
|
+
? crashes.filter((crash) => isNew(crash, comparableBase))
|
|
40
|
+
: crashes;
|
|
41
|
+
const knownCrashes = comparableBase
|
|
42
|
+
? crashes.filter((crash) => !isNew(crash, comparableBase))
|
|
43
|
+
: [];
|
|
44
|
+
const fallbackCount = crashes.filter((crash) => crash.fallback).length;
|
|
45
|
+
console.log(c.bold("SafeTS Runtime Safety Report"));
|
|
46
|
+
console.log(c.dim("-".repeat(44)));
|
|
47
|
+
console.log(comparableBase
|
|
48
|
+
? `${crashes.length} potential crashes (${c.red(`${newCrashes.length} new`)} · ${c.dim(`${knownCrashes.length} known`)})`
|
|
49
|
+
: c.red(`${crashes.length} potential crashes`));
|
|
50
|
+
if (fallbackCount > 0) {
|
|
51
|
+
console.log(c.dim(` ${fallbackCount} in fallback mode (lower confidence)`));
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
const grouped = new Map();
|
|
55
|
+
for (const crash of crashes) {
|
|
56
|
+
const key = rel(crash.file);
|
|
57
|
+
if (!grouped.has(key)) {
|
|
58
|
+
grouped.set(key, []);
|
|
59
|
+
}
|
|
60
|
+
const bucket = grouped.get(key);
|
|
61
|
+
if (bucket) {
|
|
62
|
+
bucket.push(crash);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const [file, list] of grouped) {
|
|
66
|
+
console.log(c.cyan(` ${file}`));
|
|
67
|
+
for (const crash of list) {
|
|
68
|
+
const badge = comparableBase
|
|
69
|
+
? isNew(crash, comparableBase)
|
|
70
|
+
? c.red(" [NEW] ")
|
|
71
|
+
: c.dim(" [known] ")
|
|
72
|
+
: "";
|
|
73
|
+
const confidence = crash.confidence === "HIGH" ? c.red("HIGH") : c.yellow("MED ");
|
|
74
|
+
const fallbackBadge = crash.fallback ? c.dim(" [fallback]") : "";
|
|
75
|
+
console.log(`\n ${badge} ${confidence} Line ${crash.line}:${crash.col} ${c.bold(crash.pattern)}${fallbackBadge}`);
|
|
76
|
+
console.log(c.dim(` ${crash.expr}`));
|
|
77
|
+
console.log(c.dim(` type: ${crash.type}`));
|
|
78
|
+
console.log(c.dim("\n Crash simulation:"));
|
|
79
|
+
crash.crashPath.forEach((step) => console.log(c.dim(` -> ${step}`)));
|
|
80
|
+
}
|
|
81
|
+
console.log();
|
|
82
|
+
}
|
|
83
|
+
console.log(c.dim("-".repeat(44)));
|
|
84
|
+
console.log(c.dim(" safets fix - fix suggestions"));
|
|
85
|
+
console.log(c.dim(" safets debt - grouped debt report"));
|
|
86
|
+
console.log(c.dim(" safets baseline - record current state for CI\n"));
|
|
87
|
+
if (failOnNew && newCrashes.length > 0) {
|
|
88
|
+
console.log(c.red(`x ${newCrashes.length} new crash(es) - CI blocked.\n`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function printDebt(crashes, base, baselineMismatch) {
|
|
93
|
+
const comparableBase = baselineMismatch ? null : base;
|
|
94
|
+
const currentCounts = new Map();
|
|
95
|
+
for (const crash of crashes) {
|
|
96
|
+
currentCounts.set(crash.pattern, (currentCounts.get(crash.pattern) ?? 0) + 1);
|
|
97
|
+
}
|
|
98
|
+
const baselineCounts = new Map();
|
|
99
|
+
if (comparableBase) {
|
|
100
|
+
for (const entry of comparableBase.crashes) {
|
|
101
|
+
if (entry.pattern) {
|
|
102
|
+
baselineCounts.set(entry.pattern, (baselineCounts.get(entry.pattern) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
console.log(c.bold("SafeTS Debt Report"));
|
|
107
|
+
console.log(c.dim("-".repeat(50)));
|
|
108
|
+
const patterns = new Set([
|
|
109
|
+
...currentCounts.keys(),
|
|
110
|
+
...baselineCounts.keys(),
|
|
111
|
+
]);
|
|
112
|
+
for (const pattern of patterns) {
|
|
113
|
+
const currentCount = currentCounts.get(pattern) ?? 0;
|
|
114
|
+
if (!comparableBase) {
|
|
115
|
+
console.log(` ${pattern.padEnd(40)} ${c.red(String(currentCount))}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const baselineCount = baselineCounts.get(pattern) ?? 0;
|
|
119
|
+
const delta = currentCount - baselineCount;
|
|
120
|
+
let deltaText = c.dim(" (same)");
|
|
121
|
+
if (delta > 0) {
|
|
122
|
+
deltaText = c.red(` (↑${delta} new)`);
|
|
123
|
+
}
|
|
124
|
+
else if (delta < 0) {
|
|
125
|
+
deltaText = c.green(` (↓${Math.abs(delta)} fixed)`);
|
|
126
|
+
}
|
|
127
|
+
console.log(` ${pattern.padEnd(40)} ${c.red(String(currentCount))}${deltaText}`);
|
|
128
|
+
}
|
|
129
|
+
console.log(c.dim("-".repeat(50)));
|
|
130
|
+
const total = crashes.length;
|
|
131
|
+
if (comparableBase) {
|
|
132
|
+
const delta = total - comparableBase.crashes.length;
|
|
133
|
+
let deltaText = c.dim(" (same as baseline)");
|
|
134
|
+
if (delta > 0) {
|
|
135
|
+
deltaText = c.red(` (+${delta} since baseline)`);
|
|
136
|
+
}
|
|
137
|
+
else if (delta < 0) {
|
|
138
|
+
deltaText = c.green(` (-${Math.abs(delta)} since baseline)`);
|
|
139
|
+
}
|
|
140
|
+
console.log(` ${"Total".padEnd(40)} ${c.red(String(total))}${deltaText}`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
console.log(` ${"Total".padEnd(40)} ${c.red(String(total))}`);
|
|
144
|
+
if (baselineMismatch) {
|
|
145
|
+
console.log(c.dim("\n Showing current debt only because baseline comparison was skipped."));
|
|
146
|
+
console.log(c.dim(" Re-run 'safets baseline' with matching options to track debt deltas."));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
console.log(c.dim("\n Run 'safets baseline' to track debt over time."));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
console.log();
|
|
153
|
+
}
|
|
154
|
+
export function printBaselineMismatchWarning(baselineMismatch) {
|
|
155
|
+
if (baselineMismatch) {
|
|
156
|
+
console.log(c.yellow(` ! Baseline mismatch: ${baselineMismatch}\n`));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function printFix(crashes, root) {
|
|
160
|
+
const rel = (filePath) => path.relative(root, filePath);
|
|
161
|
+
console.log(c.bold("SafeTS Fix Suggestions"));
|
|
162
|
+
console.log(c.dim("-".repeat(44)));
|
|
163
|
+
console.log(c.dim(" SafeTS is read-only - it never modifies your source code."));
|
|
164
|
+
console.log(c.dim(" Apply these suggestions manually.\n"));
|
|
165
|
+
if (crashes.length === 0) {
|
|
166
|
+
console.log(c.green(" No manual fixes to suggest right now."));
|
|
167
|
+
console.log(c.dim(" SafeTS did not find any supported runtime-crash patterns in the scanned files."));
|
|
168
|
+
console.log(c.dim(" If you expected findings, try 'safets doctor --include-tests'."));
|
|
169
|
+
console.log();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (const crash of crashes) {
|
|
173
|
+
console.log(c.cyan(`\n ${rel(crash.file)}:${crash.line} ${crash.pattern}`));
|
|
174
|
+
console.log(c.dim(` ${crash.expr}`));
|
|
175
|
+
switch (crash.pattern) {
|
|
176
|
+
case "Unsafe property access":
|
|
177
|
+
console.log(c.green(`\n -> ${makeOptionalChainSuggestion(crash.expr)}`));
|
|
178
|
+
console.log(c.green(` -> if (!${crash.rootExpr}) return;`));
|
|
179
|
+
break;
|
|
180
|
+
case "Unsafe destructuring":
|
|
181
|
+
console.log(c.green(`\n -> if (!${crash.rootExpr}) return;\n ${crash.expr}`));
|
|
182
|
+
break;
|
|
183
|
+
case "Unsafe array index access":
|
|
184
|
+
case "Unsafe Map/Record access":
|
|
185
|
+
console.log(c.green(`\n -> const item = ${crash.rootExpr}; if (!item) return;`));
|
|
186
|
+
console.log(c.green(` -> ${crash.rootExpr}?.${crash.expr.split(".").pop()}`));
|
|
187
|
+
break;
|
|
188
|
+
case "Unprotected JSON.parse":
|
|
189
|
+
console.log(c.green(`\n -> try { ${crash.expr} } catch (e) { /* handle SyntaxError */ }`));
|
|
190
|
+
break;
|
|
191
|
+
case "Unsafe process.env access":
|
|
192
|
+
console.log(c.green(`\n -> const val = process.env.${crash.rootExpr.split(".")[2]} ?? "default";`));
|
|
193
|
+
console.log(c.green(" -> Validate all env vars at startup in a dedicated config.ts"));
|
|
194
|
+
break;
|
|
195
|
+
case "Non-null assertion on nullable":
|
|
196
|
+
console.log(c.green(`\n -> Replace ! with: if (!${crash.rootExpr}) return;`));
|
|
197
|
+
console.log(c.green(` -> Or: ${crash.rootExpr}?.yourMethod()`));
|
|
198
|
+
break;
|
|
199
|
+
case "Unsafe access after await":
|
|
200
|
+
console.log(c.green(`\n -> Re-check after await:\n await doSomething();\n if (!${crash.rootExpr}) return;`));
|
|
201
|
+
break;
|
|
202
|
+
case "Unsafe Promise.all destructuring":
|
|
203
|
+
console.log(c.green(`\n -> const [item] = await Promise.all([...]);\n if (!item) return;`));
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
console.log();
|
|
208
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Baseline, CrashReport, PatternName, ProgramResult } from "../utils/types.ts";
|
|
2
|
+
type JsonCommand = "doctor" | "fix" | "debt" | "baseline";
|
|
3
|
+
interface BuildJsonReportOptions {
|
|
4
|
+
command: JsonCommand;
|
|
5
|
+
crashes: CrashReport[];
|
|
6
|
+
root: string;
|
|
7
|
+
version: string;
|
|
8
|
+
includeTests: boolean;
|
|
9
|
+
failOnNew: boolean;
|
|
10
|
+
baseline: Baseline | null;
|
|
11
|
+
baselineMismatch: string | null;
|
|
12
|
+
programResult: ProgramResult;
|
|
13
|
+
savedBaseline?: Baseline | null;
|
|
14
|
+
}
|
|
15
|
+
export declare function buildJsonReport(options: BuildJsonReportOptions): {
|
|
16
|
+
schemaVersion: number;
|
|
17
|
+
safetsVersion: string;
|
|
18
|
+
command: JsonCommand;
|
|
19
|
+
options: {
|
|
20
|
+
includeTests: boolean;
|
|
21
|
+
failOnNew: boolean;
|
|
22
|
+
};
|
|
23
|
+
program: {
|
|
24
|
+
fallback: boolean;
|
|
25
|
+
warnings: string[];
|
|
26
|
+
strategy: "root-tsconfig" | "workspace-tsconfigs" | "direct-scan" | "fallback";
|
|
27
|
+
configFiles: string[];
|
|
28
|
+
rootFileCount: number;
|
|
29
|
+
filteredFileCount: number;
|
|
30
|
+
};
|
|
31
|
+
baseline: {
|
|
32
|
+
present: boolean;
|
|
33
|
+
compatible: boolean | null;
|
|
34
|
+
mismatch: string | null;
|
|
35
|
+
crashCount: number | null;
|
|
36
|
+
saved: {
|
|
37
|
+
file: string;
|
|
38
|
+
crashCount: number;
|
|
39
|
+
options: import("../utils/types.ts").BaselineOptions | null;
|
|
40
|
+
} | null;
|
|
41
|
+
};
|
|
42
|
+
summary: {
|
|
43
|
+
total: number;
|
|
44
|
+
new: number;
|
|
45
|
+
known: number;
|
|
46
|
+
byPattern: Partial<Record<PatternName, number>>;
|
|
47
|
+
fallback: number;
|
|
48
|
+
};
|
|
49
|
+
debt: {
|
|
50
|
+
pattern: PatternName;
|
|
51
|
+
current: number;
|
|
52
|
+
baseline: number | null;
|
|
53
|
+
delta: number | null;
|
|
54
|
+
}[];
|
|
55
|
+
crashes: {
|
|
56
|
+
file: string;
|
|
57
|
+
line: number;
|
|
58
|
+
col: number;
|
|
59
|
+
expr: string;
|
|
60
|
+
rootExpr: string;
|
|
61
|
+
type: string;
|
|
62
|
+
pattern: PatternName;
|
|
63
|
+
confidence: "HIGH" | "MEDIUM";
|
|
64
|
+
fallback: boolean;
|
|
65
|
+
status: string;
|
|
66
|
+
crashPath: string[];
|
|
67
|
+
suggestions: string[];
|
|
68
|
+
}[];
|
|
69
|
+
};
|
|
70
|
+
export declare function printJsonReport(options: BuildJsonReportOptions): void;
|
|
71
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { isNew } from "./baseline.js";
|
|
2
|
+
import { toProjectRelativePath } from "../utils/files.js";
|
|
3
|
+
function countByPattern(patterns) {
|
|
4
|
+
const counts = {};
|
|
5
|
+
for (const pattern of patterns) {
|
|
6
|
+
counts[pattern] = (counts[pattern] ?? 0) + 1;
|
|
7
|
+
}
|
|
8
|
+
return counts;
|
|
9
|
+
}
|
|
10
|
+
function makeOptionalChainSuggestion(expr) {
|
|
11
|
+
const lastDot = expr.lastIndexOf(".");
|
|
12
|
+
const lastBracket = expr.lastIndexOf("[");
|
|
13
|
+
if (lastBracket > lastDot) {
|
|
14
|
+
return `${expr.slice(0, lastBracket)}?.${expr.slice(lastBracket)}`;
|
|
15
|
+
}
|
|
16
|
+
if (lastDot !== -1) {
|
|
17
|
+
return `${expr.slice(0, lastDot)}?.${expr.slice(lastDot + 1)}`;
|
|
18
|
+
}
|
|
19
|
+
return expr;
|
|
20
|
+
}
|
|
21
|
+
function makeEnvSuggestion(rootExpr) {
|
|
22
|
+
const match = rootExpr.match(/process\.env(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[["']([^"']+)["']\])/);
|
|
23
|
+
const envName = match?.[1] ?? match?.[2] ?? "VAR_NAME";
|
|
24
|
+
const isIdentifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(envName);
|
|
25
|
+
const envAccess = isIdentifier
|
|
26
|
+
? `process.env.${envName}`
|
|
27
|
+
: `process.env["${envName}"]`;
|
|
28
|
+
return `const val = ${envAccess} ?? "default";`;
|
|
29
|
+
}
|
|
30
|
+
function makeOptionalAccessSuggestion(rootExpr, expr) {
|
|
31
|
+
const suffix = expr.startsWith(rootExpr) ? expr.slice(rootExpr.length) : "";
|
|
32
|
+
if (suffix.startsWith(".")) {
|
|
33
|
+
return `${rootExpr}?.${suffix.slice(1)}`;
|
|
34
|
+
}
|
|
35
|
+
if (suffix.startsWith("[")) {
|
|
36
|
+
return `${rootExpr}?.${suffix}`;
|
|
37
|
+
}
|
|
38
|
+
return `${rootExpr}?.${suffix || "property"}`;
|
|
39
|
+
}
|
|
40
|
+
function makeFixSuggestions(crash) {
|
|
41
|
+
switch (crash.pattern) {
|
|
42
|
+
case "Unsafe property access":
|
|
43
|
+
return [
|
|
44
|
+
makeOptionalChainSuggestion(crash.expr),
|
|
45
|
+
`if (!${crash.rootExpr}) return;`,
|
|
46
|
+
];
|
|
47
|
+
case "Unsafe destructuring":
|
|
48
|
+
return [`if (!${crash.rootExpr}) return;`, crash.expr];
|
|
49
|
+
case "Unsafe array index access":
|
|
50
|
+
case "Unsafe Map/Record access":
|
|
51
|
+
return [
|
|
52
|
+
`const item = ${crash.rootExpr}; if (!item) return;`,
|
|
53
|
+
makeOptionalAccessSuggestion(crash.rootExpr, crash.expr),
|
|
54
|
+
];
|
|
55
|
+
case "Unprotected JSON.parse":
|
|
56
|
+
return [`try { ${crash.expr} } catch (e) { /* handle SyntaxError */ }`];
|
|
57
|
+
case "Unsafe process.env access":
|
|
58
|
+
return [
|
|
59
|
+
makeEnvSuggestion(crash.rootExpr),
|
|
60
|
+
"Validate all env vars at startup in a dedicated config.ts",
|
|
61
|
+
];
|
|
62
|
+
case "Non-null assertion on nullable":
|
|
63
|
+
return [
|
|
64
|
+
`Replace ! with: if (!${crash.rootExpr}) return;`,
|
|
65
|
+
`${crash.rootExpr}?.yourMethod()`,
|
|
66
|
+
];
|
|
67
|
+
case "Unsafe access after await":
|
|
68
|
+
return [
|
|
69
|
+
`await doSomething(); if (!${crash.rootExpr}) return;`,
|
|
70
|
+
];
|
|
71
|
+
case "Unsafe Promise.all destructuring":
|
|
72
|
+
return ["const [item] = await Promise.all([...]); if (!item) return;"];
|
|
73
|
+
default:
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function buildJsonReport(options) {
|
|
78
|
+
const effectiveBaseline = options.command === "baseline"
|
|
79
|
+
? options.savedBaseline ?? options.baseline
|
|
80
|
+
: options.baseline;
|
|
81
|
+
const effectiveMismatch = options.command === "baseline" && options.savedBaseline
|
|
82
|
+
? null
|
|
83
|
+
: options.baselineMismatch;
|
|
84
|
+
const comparableBaseline = effectiveMismatch ? null : effectiveBaseline;
|
|
85
|
+
const newCrashes = comparableBaseline
|
|
86
|
+
? options.crashes.filter((crash) => isNew(crash, comparableBaseline))
|
|
87
|
+
: options.crashes;
|
|
88
|
+
const knownCrashes = comparableBaseline
|
|
89
|
+
? options.crashes.filter((crash) => !isNew(crash, comparableBaseline))
|
|
90
|
+
: [];
|
|
91
|
+
const baselinePatternCounts = countByPattern(comparableBaseline
|
|
92
|
+
? comparableBaseline.crashes
|
|
93
|
+
.map((crash) => crash.pattern)
|
|
94
|
+
.filter((pattern) => !!pattern)
|
|
95
|
+
: []);
|
|
96
|
+
const currentPatternCounts = countByPattern(options.crashes.map((crash) => crash.pattern));
|
|
97
|
+
const debt = Object.keys({
|
|
98
|
+
...baselinePatternCounts,
|
|
99
|
+
...currentPatternCounts,
|
|
100
|
+
}).map((pattern) => {
|
|
101
|
+
const name = pattern;
|
|
102
|
+
const current = currentPatternCounts[name] ?? 0;
|
|
103
|
+
const baselineCount = baselinePatternCounts[name] ?? 0;
|
|
104
|
+
return {
|
|
105
|
+
pattern: name,
|
|
106
|
+
current,
|
|
107
|
+
baseline: comparableBaseline ? baselineCount : null,
|
|
108
|
+
delta: comparableBaseline ? current - baselineCount : null,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
schemaVersion: 1,
|
|
113
|
+
safetsVersion: options.version,
|
|
114
|
+
command: options.command,
|
|
115
|
+
options: {
|
|
116
|
+
includeTests: options.includeTests,
|
|
117
|
+
failOnNew: options.failOnNew,
|
|
118
|
+
},
|
|
119
|
+
program: {
|
|
120
|
+
fallback: options.programResult.fallback,
|
|
121
|
+
warnings: options.programResult.warnings,
|
|
122
|
+
strategy: options.programResult.strategy,
|
|
123
|
+
configFiles: options.programResult.configFiles.map((filePath) => toProjectRelativePath(options.root, filePath)),
|
|
124
|
+
rootFileCount: options.programResult.rootFileCount,
|
|
125
|
+
filteredFileCount: options.programResult.filteredFileCount,
|
|
126
|
+
},
|
|
127
|
+
baseline: {
|
|
128
|
+
present: effectiveBaseline !== null,
|
|
129
|
+
compatible: effectiveBaseline !== null ? effectiveMismatch === null : null,
|
|
130
|
+
mismatch: effectiveMismatch,
|
|
131
|
+
crashCount: effectiveBaseline?.crashes.length ?? null,
|
|
132
|
+
saved: options.savedBaseline
|
|
133
|
+
? {
|
|
134
|
+
file: ".safets-baseline.json",
|
|
135
|
+
crashCount: options.savedBaseline.crashes.length,
|
|
136
|
+
options: options.savedBaseline.options ?? null,
|
|
137
|
+
}
|
|
138
|
+
: null,
|
|
139
|
+
},
|
|
140
|
+
summary: {
|
|
141
|
+
total: options.crashes.length,
|
|
142
|
+
new: newCrashes.length,
|
|
143
|
+
known: knownCrashes.length,
|
|
144
|
+
byPattern: currentPatternCounts,
|
|
145
|
+
fallback: options.crashes.filter((crash) => crash.fallback).length,
|
|
146
|
+
},
|
|
147
|
+
debt,
|
|
148
|
+
crashes: options.crashes.map((crash) => ({
|
|
149
|
+
file: toProjectRelativePath(options.root, crash.file),
|
|
150
|
+
line: crash.line,
|
|
151
|
+
col: crash.col,
|
|
152
|
+
expr: crash.expr,
|
|
153
|
+
rootExpr: crash.rootExpr,
|
|
154
|
+
type: crash.type,
|
|
155
|
+
pattern: crash.pattern,
|
|
156
|
+
confidence: crash.confidence,
|
|
157
|
+
fallback: crash.fallback ?? false,
|
|
158
|
+
status: comparableBaseline
|
|
159
|
+
? isNew(crash, comparableBaseline)
|
|
160
|
+
? "new"
|
|
161
|
+
: "known"
|
|
162
|
+
: "current",
|
|
163
|
+
crashPath: crash.crashPath,
|
|
164
|
+
suggestions: makeFixSuggestions(crash),
|
|
165
|
+
})),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export function printJsonReport(options) {
|
|
169
|
+
console.log(JSON.stringify(buildJsonReport(options), null, 2));
|
|
170
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
export declare function isNullable(type: ts.Type): boolean;
|
|
3
|
+
export declare function isInsideTryCatch(node: ts.Node): boolean;
|
|
4
|
+
export declare function pos(sf: ts.SourceFile, node: ts.Node): {
|
|
5
|
+
line: number;
|
|
6
|
+
col: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function getChainRoot(expr: ts.Expression): ts.Expression;
|
|
9
|
+
export declare function isSubChainDuplicate(node: ts.PropertyAccessExpression, checker: ts.TypeChecker): boolean;
|
|
10
|
+
export declare function isOptionalAccess(node: ts.PropertyAccessExpression): boolean;
|
|
11
|
+
export declare function hasNonNullAssertion(node: ts.PropertyAccessExpression): boolean;
|