@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dioman Keita
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# SafeTS
|
|
2
|
+
|
|
3
|
+
**Finds common runtime crashes TypeScript can't detect.**
|
|
4
|
+
|
|
5
|
+
TypeScript catches type errors at compile time, but some crash patterns slip through even with `strict: true`. SafeTS uses the TypeScript Compiler API to detect them before they hit production.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Cannot read properties of undefined (reading 'name')
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install --save-dev @safets-org/cli typescript
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
No runtime TypeScript loader is required. SafeTS uses the TypeScript compiler already in your project.
|
|
20
|
+
|
|
21
|
+
The npm package is scoped as `@safets-org/cli`, but the installed command is still `safets`.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
safets doctor
|
|
29
|
+
safets doctor --include-tests
|
|
30
|
+
safets fix
|
|
31
|
+
safets debt
|
|
32
|
+
safets baseline
|
|
33
|
+
safets doctor --fail-on-new
|
|
34
|
+
safets doctor --json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Common flags:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
safets --help
|
|
41
|
+
safets --version
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### JSON Output
|
|
45
|
+
|
|
46
|
+
Use `--json` when SafeTS output needs to be consumed by CI, scripts, bots, or editor integrations.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
safets doctor --json
|
|
50
|
+
safets debt --json
|
|
51
|
+
safets fix --json
|
|
52
|
+
safets baseline --json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The JSON schema is versioned with `schemaVersion`. Each report includes:
|
|
56
|
+
|
|
57
|
+
- `safetsVersion`, `command`, and scan `options`
|
|
58
|
+
- `program.strategy`, `program.configFiles`, `program.fallback`, and `program.warnings`
|
|
59
|
+
- `baseline.present`, `baseline.compatible`, `baseline.mismatch`, and saved baseline metadata when relevant
|
|
60
|
+
- `summary.total`, `summary.new`, `summary.known`, `summary.byPattern`, and fallback count
|
|
61
|
+
- `debt[]` entries with current count, baseline count, and delta when a compatible baseline exists
|
|
62
|
+
- `crashes[]` entries with project-relative file path, location, expression, root expression, type, pattern, confidence, baseline status, crash path, and suggestions
|
|
63
|
+
|
|
64
|
+
`doctor --json --fail-on-new` still exits with code `1` when new crashes are found, but prints the JSON report first.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## How It Works
|
|
69
|
+
|
|
70
|
+
**SafeTS is read-only.** It never modifies your source code. `safets fix` only prints suggestions to stdout, and you apply them manually.
|
|
71
|
+
|
|
72
|
+
SafeTS scans your TypeScript files using the Compiler API, builds a type-checked program, and walks the AST looking for patterns that TypeScript's own checker allows but that can still crash at runtime.
|
|
73
|
+
|
|
74
|
+
Test files (`*.test.ts`, `*.spec.ts`, `/__tests__/`, and similar paths) are excluded by default. Use `--include-tests` to include them.
|
|
75
|
+
|
|
76
|
+
Files over 5000 lines, such as compiled bundles or generated Prisma clients, are automatically skipped.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Real-World Validation
|
|
81
|
+
|
|
82
|
+
SafeTS v1.0.0 was tested in zero-setup mode against public TypeScript repositories: clone, build SafeTS, then run `safets doctor --json` without installing each target repo's dependencies. Test files and test-only tsconfigs are excluded by default, matching normal SafeTS CLI behavior.
|
|
83
|
+
|
|
84
|
+
| Repository | TS/TSX files | Strategy | Result | Duration | Perf | Fallback | Findings |
|
|
85
|
+
| --- | ---: | --- | --- | ---: | --- | --- | ---: |
|
|
86
|
+
| `google-gemini/gemini-cli` | 2108 | root-tsconfig | ok | 16s | ok | false | 247 |
|
|
87
|
+
| `vitejs/vite` | 563 | workspace-tsconfigs | ok | 21s | ok | false | 43 |
|
|
88
|
+
| `prisma/prisma` | 2701 | root-tsconfig | ok | 15s | ok | false | 267 |
|
|
89
|
+
| `supabase/supabase` | 6669 | root-tsconfig | ok | 24s | ok | false | 157 |
|
|
90
|
+
| `vitest-dev/vitest` | 2038 | workspace-tsconfigs | ok | 35s | ok | false | 298 |
|
|
91
|
+
| `withastro/astro` | 2094 | workspace-tsconfigs | ok | 24s | ok | false | 394 |
|
|
92
|
+
|
|
93
|
+
No target fell back to AST-only mode. See [docs/real-world-validation.md](./docs/real-world-validation.md) for commits, pattern breakdowns, methodology, and follow-up notes.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## The 9 Patterns
|
|
98
|
+
|
|
99
|
+
| Pattern | Confidence | Example |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| Unsafe property access | HIGH | `user.profile.name` when `user` is `User \| undefined` |
|
|
102
|
+
| Unsafe destructuring | HIGH | `const { name } = user` when `user` is nullable |
|
|
103
|
+
| Unsafe array index access | HIGH | `arr[0].name` when `arr[0]` may be `undefined` |
|
|
104
|
+
| Unprotected JSON.parse | HIGH | `JSON.parse(input)` without `try/catch` |
|
|
105
|
+
| Unsafe process.env access | HIGH | `process.env.API_KEY` used directly |
|
|
106
|
+
| Non-null assertion on nullable | MEDIUM | `value!.method()` when `value` may be `undefined` |
|
|
107
|
+
| Unsafe access after await | MEDIUM | narrowing becomes stale after an `await` boundary |
|
|
108
|
+
| Unsafe Promise.all destructuring | MEDIUM | `const [a] = await Promise.all([...])` when result may be `undefined` |
|
|
109
|
+
| Unsafe Map/Record access | HIGH | `map[key].value` when key may not exist |
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Baseline And CI
|
|
114
|
+
|
|
115
|
+
The baseline system lets you track debt without blocking existing work.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
safets baseline
|
|
119
|
+
# creates .safets-baseline.json at the project root
|
|
120
|
+
|
|
121
|
+
git add .safets-baseline.json
|
|
122
|
+
git commit -m "chore: add SafeTS baseline"
|
|
123
|
+
|
|
124
|
+
safets doctor --fail-on-new
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`.safets-baseline.json` should be committed to version control so every teammate and CI job compares against the same snapshot.
|
|
128
|
+
|
|
129
|
+
The baseline stores scan options such as `includeTests`. If you run `doctor --fail-on-new` with different options than the saved baseline, SafeTS will refuse the comparison and ask you to regenerate the baseline.
|
|
130
|
+
|
|
131
|
+
### GitHub Action
|
|
132
|
+
|
|
133
|
+
SafeTS can run directly in GitHub Actions:
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
- uses: Dioman-Keita/safets@v1.0.0
|
|
137
|
+
with:
|
|
138
|
+
fail-on-new: "true"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See [docs/github-action.md](./docs/github-action.md) for the full workflow, inputs, and baseline setup.
|
|
142
|
+
|
|
143
|
+
### Debt Tracking
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
safets debt
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
When a baseline exists, `debt` shows the delta per category since the snapshot.
|
|
150
|
+
|
|
151
|
+
## Exit Codes
|
|
152
|
+
|
|
153
|
+
- `0`: successful run
|
|
154
|
+
- `1`: invalid CLI usage, incompatible `doctor --fail-on-new` baseline, or new crashes found with `--fail-on-new`
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
161
|
+
|
|
162
|
+
## Releases
|
|
163
|
+
|
|
164
|
+
SafeTS releases follow the documented workflow in [docs/release.md](./docs/release.md).
|
|
165
|
+
|
|
166
|
+
## Contributing
|
|
167
|
+
|
|
168
|
+
Detector architecture and contribution expectations are documented in [docs/detectors.md](./docs/detectors.md).
|
|
169
|
+
|
|
170
|
+
## Roadmap
|
|
171
|
+
|
|
172
|
+
The launch plan is tracked in [ROADMAP.md](./ROADMAP.md).
|
package/dist/analyze.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { detectFallbackPatterns, detectNonNullAssertionOnNullable, detectUnsafeAccessAfterAwait, detectUnsafeArrayAccess, detectUnsafeDestructuring, detectUnsafeEnvAccess, detectUnsafeJsonParse, detectUnsafeMapAccess, detectUnsafePromiseAllDestructuring, detectUnsafePropertyAccess, } from "./detectors/index.js";
|
|
5
|
+
import { findTsConfigFiles, findTsFiles, isAnalyzableTsFile, isTestFile, normalizeFilePath } from "./utils/files.js";
|
|
6
|
+
function filterProgramFiles(fileNames) {
|
|
7
|
+
return fileNames
|
|
8
|
+
.map((fileName) => normalizeFilePath(fileName))
|
|
9
|
+
.filter((fileName) => isAnalyzableTsFile(fileName));
|
|
10
|
+
}
|
|
11
|
+
function isCheckerUsable(program) {
|
|
12
|
+
try {
|
|
13
|
+
const checker = program.getTypeChecker();
|
|
14
|
+
const sourceFiles = program.getSourceFiles();
|
|
15
|
+
const firstUserFile = sourceFiles.find((sf) => !sf.isDeclarationFile && !sf.fileName.includes("node_modules"));
|
|
16
|
+
if (!firstUserFile) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
checker.getSymbolsInScope(firstUserFile, ts.SymbolFlags.Variable);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function loadTsConfigProgram(configPath, createProgram = true) {
|
|
27
|
+
try {
|
|
28
|
+
const { config, error } = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
29
|
+
if (error) {
|
|
30
|
+
return {
|
|
31
|
+
program: null,
|
|
32
|
+
options: {},
|
|
33
|
+
fileNames: [],
|
|
34
|
+
filteredFileNames: [],
|
|
35
|
+
errors: [error],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const { options, fileNames, errors } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath), undefined, configPath);
|
|
39
|
+
const filteredFileNames = filterProgramFiles(fileNames);
|
|
40
|
+
if (filteredFileNames.length === 0 || !createProgram) {
|
|
41
|
+
return { program: null, options, fileNames, filteredFileNames, errors };
|
|
42
|
+
}
|
|
43
|
+
const program = ts.createProgram(filteredFileNames, {
|
|
44
|
+
...options,
|
|
45
|
+
noEmit: true,
|
|
46
|
+
skipLibCheck: true,
|
|
47
|
+
});
|
|
48
|
+
return { program, options, fileNames, filteredFileNames, errors };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
program: null,
|
|
53
|
+
options: {},
|
|
54
|
+
fileNames: [],
|
|
55
|
+
filteredFileNames: [],
|
|
56
|
+
errors: [error],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function uniqueFiles(fileNames) {
|
|
61
|
+
return [...new Set(fileNames.map((fileName) => normalizeFilePath(fileName)))];
|
|
62
|
+
}
|
|
63
|
+
export function loadProgramRobust(projectRoot, includeTests) {
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const rootConfigPath = path.join(projectRoot, "tsconfig.json");
|
|
66
|
+
try {
|
|
67
|
+
if (ts.sys.fileExists(rootConfigPath)) {
|
|
68
|
+
const result = loadTsConfigProgram(rootConfigPath);
|
|
69
|
+
if (result.program && isCheckerUsable(result.program)) {
|
|
70
|
+
if (result.errors.length > 0) {
|
|
71
|
+
warnings.push(`tsconfig has ${result.errors.length} issue(s) - analysis may be partial`);
|
|
72
|
+
}
|
|
73
|
+
if (result.filteredFileNames.length !== result.fileNames.length) {
|
|
74
|
+
warnings.push(`Filtered ${result.fileNames.length - result.filteredFileNames.length} generated or bundled file(s) from tsconfig inputs`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
program: result.program,
|
|
78
|
+
fallback: false,
|
|
79
|
+
warnings,
|
|
80
|
+
includeTests,
|
|
81
|
+
strategy: "root-tsconfig",
|
|
82
|
+
configFiles: [rootConfigPath],
|
|
83
|
+
rootFileCount: result.filteredFileNames.length,
|
|
84
|
+
filteredFileCount: result.fileNames.length - result.filteredFileNames.length,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (result.filteredFileNames.length > 0) {
|
|
88
|
+
warnings.push(result.errors.length > 0
|
|
89
|
+
? "tsconfig.json found but could not be parsed"
|
|
90
|
+
: "TypeChecker built but unusable - trying fallback options");
|
|
91
|
+
}
|
|
92
|
+
else if (result.errors.length > 0) {
|
|
93
|
+
warnings.push("tsconfig.json found but could not be parsed");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
warnings.push(`tsconfig load error: ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const workspaceConfigPaths = findTsConfigFiles(projectRoot, includeTests)
|
|
102
|
+
.filter((configPath) => normalizeFilePath(configPath) !== normalizeFilePath(rootConfigPath))
|
|
103
|
+
.sort();
|
|
104
|
+
const allFileNames = [];
|
|
105
|
+
const allFilteredFileNames = [];
|
|
106
|
+
const workspaceResults = [];
|
|
107
|
+
let totalErrors = 0;
|
|
108
|
+
for (const configPath of workspaceConfigPaths) {
|
|
109
|
+
const result = loadTsConfigProgram(configPath, false);
|
|
110
|
+
totalErrors += result.errors.length;
|
|
111
|
+
if (result.filteredFileNames.length > 0) {
|
|
112
|
+
allFileNames.push(...result.fileNames);
|
|
113
|
+
allFilteredFileNames.push(...result.filteredFileNames);
|
|
114
|
+
workspaceResults.push({ configPath, result });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const uniqueFilteredFileNames = uniqueFiles(allFilteredFileNames.filter((fileName) => includeTests || !isTestFile(fileName)));
|
|
118
|
+
const directFiles = findTsFiles(projectRoot).filter((fileName) => includeTests || !isTestFile(fileName));
|
|
119
|
+
if (uniqueFilteredFileNames.length > 0) {
|
|
120
|
+
const coveredFiles = new Set(uniqueFilteredFileNames);
|
|
121
|
+
const uncoveredFiles = directFiles.filter((fileName) => !coveredFiles.has(normalizeFilePath(fileName)));
|
|
122
|
+
const programInputs = [];
|
|
123
|
+
const scheduledFiles = new Set();
|
|
124
|
+
for (const { configPath, result } of workspaceResults) {
|
|
125
|
+
const fileNames = [];
|
|
126
|
+
for (const fileName of result.filteredFileNames) {
|
|
127
|
+
const normalizedFileName = normalizeFilePath(fileName);
|
|
128
|
+
if (!includeTests && isTestFile(normalizedFileName)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (scheduledFiles.has(normalizedFileName)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
scheduledFiles.add(normalizedFileName);
|
|
135
|
+
fileNames.push(normalizedFileName);
|
|
136
|
+
}
|
|
137
|
+
if (fileNames.length === 0) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
programInputs.push({
|
|
141
|
+
configFile: configPath,
|
|
142
|
+
fileNames,
|
|
143
|
+
options: {
|
|
144
|
+
...result.options,
|
|
145
|
+
noEmit: true,
|
|
146
|
+
skipLibCheck: true,
|
|
147
|
+
},
|
|
148
|
+
rootFileCount: fileNames.length,
|
|
149
|
+
filteredFileCount: result.fileNames.length - result.filteredFileNames.length,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (uncoveredFiles.length > 0) {
|
|
153
|
+
warnings.push(`Nested tsconfig files cover ${uniqueFilteredFileNames.length}/${directFiles.length} TypeScript file(s) - scanning ${uncoveredFiles.length} uncovered file(s) directly`);
|
|
154
|
+
const directFileNames = uncoveredFiles.filter((fileName) => {
|
|
155
|
+
const normalizedFileName = normalizeFilePath(fileName);
|
|
156
|
+
if (scheduledFiles.has(normalizedFileName)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
scheduledFiles.add(normalizedFileName);
|
|
160
|
+
return true;
|
|
161
|
+
});
|
|
162
|
+
if (directFileNames.length > 0) {
|
|
163
|
+
programInputs.push({
|
|
164
|
+
configFile: null,
|
|
165
|
+
fileNames: directFileNames,
|
|
166
|
+
options: {
|
|
167
|
+
target: ts.ScriptTarget.ESNext,
|
|
168
|
+
module: ts.ModuleKind.CommonJS,
|
|
169
|
+
strict: true,
|
|
170
|
+
noUncheckedIndexedAccess: true,
|
|
171
|
+
skipLibCheck: true,
|
|
172
|
+
noEmit: true,
|
|
173
|
+
},
|
|
174
|
+
rootFileCount: directFileNames.length,
|
|
175
|
+
filteredFileCount: 0,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (programInputs.length > 0) {
|
|
180
|
+
warnings.push(`No root tsconfig.json found - using ${workspaceResults.length} nested tsconfig.json file(s)`);
|
|
181
|
+
if (totalErrors > 0) {
|
|
182
|
+
warnings.push(`Nested tsconfig files have ${totalErrors} issue(s) - analysis may be partial`);
|
|
183
|
+
}
|
|
184
|
+
if (allFilteredFileNames.length !== allFileNames.length) {
|
|
185
|
+
warnings.push(`Filtered ${allFileNames.length - allFilteredFileNames.length} generated or bundled file(s) from nested tsconfig inputs`);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
program: null,
|
|
189
|
+
programInputs,
|
|
190
|
+
fallback: false,
|
|
191
|
+
warnings,
|
|
192
|
+
includeTests,
|
|
193
|
+
strategy: "workspace-tsconfigs",
|
|
194
|
+
configFiles: workspaceResults.map(({ configPath }) => configPath),
|
|
195
|
+
rootFileCount: uniqueFiles([...uniqueFilteredFileNames, ...uncoveredFiles]).length,
|
|
196
|
+
filteredFileCount: allFileNames.length - allFilteredFileNames.length,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
warnings.push("Nested tsconfig scan produced unusable TypeChecker");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
warnings.push(`Nested tsconfig scan error: ${error.message}`);
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const files = findTsFiles(projectRoot).filter((fileName) => includeTests || !isTestFile(fileName));
|
|
209
|
+
if (files.length > 0) {
|
|
210
|
+
const program = ts.createProgram(files, {
|
|
211
|
+
target: ts.ScriptTarget.ESNext,
|
|
212
|
+
module: ts.ModuleKind.CommonJS,
|
|
213
|
+
strict: true,
|
|
214
|
+
noUncheckedIndexedAccess: true,
|
|
215
|
+
skipLibCheck: true,
|
|
216
|
+
noEmit: true,
|
|
217
|
+
});
|
|
218
|
+
if (isCheckerUsable(program)) {
|
|
219
|
+
warnings.push("No usable tsconfig found - scanning TypeScript files directly");
|
|
220
|
+
return {
|
|
221
|
+
program,
|
|
222
|
+
fallback: false,
|
|
223
|
+
warnings,
|
|
224
|
+
includeTests,
|
|
225
|
+
strategy: "direct-scan",
|
|
226
|
+
configFiles: [],
|
|
227
|
+
rootFileCount: files.length,
|
|
228
|
+
filteredFileCount: 0,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
warnings.push("Direct scan produced unusable TypeChecker");
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
warnings.push("No TypeScript files found in project");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
warnings.push(`Direct scan error: ${error.message}`);
|
|
239
|
+
}
|
|
240
|
+
warnings.push("Running in AST-only fallback mode - results will be partial");
|
|
241
|
+
return {
|
|
242
|
+
program: null,
|
|
243
|
+
fallback: true,
|
|
244
|
+
warnings,
|
|
245
|
+
includeTests,
|
|
246
|
+
strategy: "fallback",
|
|
247
|
+
configFiles: [],
|
|
248
|
+
rootFileCount: 0,
|
|
249
|
+
filteredFileCount: 0,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function getUserSourceFiles(program, includeTests) {
|
|
253
|
+
try {
|
|
254
|
+
return program
|
|
255
|
+
.getRootFileNames()
|
|
256
|
+
.map((fileName) => program.getSourceFile(fileName))
|
|
257
|
+
.filter((sf) => sf !== undefined &&
|
|
258
|
+
!sf.isDeclarationFile &&
|
|
259
|
+
!sf.fileName.includes("node_modules") &&
|
|
260
|
+
isAnalyzableTsFile(sf.fileName) &&
|
|
261
|
+
(includeTests || !isTestFile(sf.fileName)));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return program.getSourceFiles().filter((sf) => !sf.isDeclarationFile &&
|
|
265
|
+
!sf.fileName.includes("node_modules") &&
|
|
266
|
+
isAnalyzableTsFile(sf.fileName) &&
|
|
267
|
+
(includeTests || !isTestFile(sf.fileName)));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export function analyze(programResult) {
|
|
271
|
+
const { includeTests } = programResult;
|
|
272
|
+
if (programResult.programInputs && programResult.programInputs.length > 0) {
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const all = [];
|
|
275
|
+
let oldProgram;
|
|
276
|
+
for (const entry of programResult.programInputs) {
|
|
277
|
+
try {
|
|
278
|
+
const program = ts.createProgram(entry.fileNames, entry.options, undefined, oldProgram);
|
|
279
|
+
oldProgram = program;
|
|
280
|
+
for (const crash of analyzeProgram(program, includeTests)) {
|
|
281
|
+
const key = [
|
|
282
|
+
normalizeFilePath(crash.file),
|
|
283
|
+
crash.line,
|
|
284
|
+
crash.col,
|
|
285
|
+
crash.expr,
|
|
286
|
+
crash.pattern,
|
|
287
|
+
].join("\0");
|
|
288
|
+
if (!seen.has(key)) {
|
|
289
|
+
seen.add(key);
|
|
290
|
+
all.push(crash);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
programResult.warnings.push(`Failed to analyze workspace slice for ${entry.configFile ?? "uncovered files"}: ${error.message}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return all;
|
|
299
|
+
}
|
|
300
|
+
if (programResult.fallback || !programResult.program) {
|
|
301
|
+
const files = findTsFiles(process.cwd()).filter((filePath) => includeTests || !isTestFile(filePath));
|
|
302
|
+
const all = [];
|
|
303
|
+
for (const filePath of files) {
|
|
304
|
+
try {
|
|
305
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
306
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.ESNext, true);
|
|
307
|
+
all.push(...detectFallbackPatterns(sourceFile));
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Ignore unreadable files in fallback mode.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return all;
|
|
314
|
+
}
|
|
315
|
+
return analyzeProgram(programResult.program, includeTests);
|
|
316
|
+
}
|
|
317
|
+
function analyzeProgram(program, includeTests) {
|
|
318
|
+
const checker = program.getTypeChecker();
|
|
319
|
+
const sourceFiles = getUserSourceFiles(program, includeTests);
|
|
320
|
+
const all = [];
|
|
321
|
+
for (const sourceFile of sourceFiles) {
|
|
322
|
+
try {
|
|
323
|
+
all.push(...detectUnsafePropertyAccess(sourceFile, checker), ...detectUnsafeDestructuring(sourceFile, checker), ...detectUnsafeArrayAccess(sourceFile, checker), ...detectUnsafeJsonParse(sourceFile), ...detectUnsafeEnvAccess(sourceFile, checker), ...detectNonNullAssertionOnNullable(sourceFile, checker), ...detectUnsafeAccessAfterAwait(sourceFile, checker), ...detectUnsafePromiseAllDestructuring(sourceFile, checker), ...detectUnsafeMapAccess(sourceFile, checker));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// Skip files that fail analysis instead of crashing the CLI.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return all;
|
|
330
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { CrashReport } from "../utils/types.ts";
|
|
3
|
+
export declare function detectFallbackPatterns(sf: ts.SourceFile): CrashReport[];
|
|
4
|
+
export declare function detectUnsafePropertyAccess(sf: ts.SourceFile, checker: ts.TypeChecker): CrashReport[];
|
|
5
|
+
export declare function detectUnsafeDestructuring(sf: ts.SourceFile, checker: ts.TypeChecker): CrashReport[];
|
|
6
|
+
export declare function detectUnsafeArrayAccess(sf: ts.SourceFile, checker: ts.TypeChecker): CrashReport[];
|
|
7
|
+
export declare function detectUnsafeJsonParse(sf: ts.SourceFile): CrashReport[];
|
|
8
|
+
export declare function detectUnsafeEnvAccess(sf: ts.SourceFile, checker: ts.TypeChecker): CrashReport[];
|
|
9
|
+
export declare function detectNonNullAssertionOnNullable(sf: ts.SourceFile, checker: ts.TypeChecker): CrashReport[];
|
|
10
|
+
export declare function detectUnsafeAccessAfterAwait(sf: ts.SourceFile, checker: ts.TypeChecker): CrashReport[];
|
|
11
|
+
export declare function detectUnsafePromiseAllDestructuring(sf: ts.SourceFile, checker: ts.TypeChecker): CrashReport[];
|
|
12
|
+
export declare function detectUnsafeMapAccess(sf: ts.SourceFile, checker: ts.TypeChecker): CrashReport[];
|