realapi-check 0.1.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 +111 -0
- package/dist/analyzer.d.ts +2 -0
- package/dist/analyzer.js +491 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +93 -0
- package/dist/files.d.ts +11 -0
- package/dist/files.js +78 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/packages.d.ts +37 -0
- package/dist/packages.js +175 -0
- package/dist/report.d.ts +3 -0
- package/dist/report.js +46 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.js +1 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Naveen Elango
|
|
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,111 @@
|
|
|
1
|
+
# realapi-check
|
|
2
|
+
|
|
3
|
+
**Catch AI-hallucinated imports, methods and options before they reach runtime.**
|
|
4
|
+
|
|
5
|
+
AI coding assistants write code for the library version they were trained on, not the one you installed. They invent functions, misspell methods, pass options that were removed, and import packages that don't exist. `realapi` checks your code against the **exact package versions in your `node_modules`** and tells you what isn't real.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
$ npx realapi-check
|
|
9
|
+
|
|
10
|
+
src/user.ts
|
|
11
|
+
7:22 error 'emial' does not exist on ZodString (zod@4.6.5). Did you mean 'email'? missing-member
|
|
12
|
+
const s = z.string().emial();
|
|
13
|
+
9:34 error 'retries' is not a known option of AxiosRequestConfig (axios@1.20.0) unknown-option
|
|
14
|
+
axios.get("/x", { timeout: 1000, retries: 3 });
|
|
15
|
+
3:10 error 'readFileAsync' is not exported by @types/node@26.6.3. Did you mean 'readFileSync'? missing-export
|
|
16
|
+
import { readFileAsync } from "node:fs";
|
|
17
|
+
5:32 error 'express-useragent-pro' does not exist on npm. It was probably invented; do not install it blindly missing-package
|
|
18
|
+
import { parseUserAgent } from "express-useragent-pro";
|
|
19
|
+
10:29 warning 'merge' is deprecated in zod@4.6.5: Use A.extend(B.shape) instead. deprecated
|
|
20
|
+
const merged = a.merge(b);
|
|
21
|
+
|
|
22
|
+
✖ 5 problems (4 errors, 1 warning) checked 42 files against 12 installed packages
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What it catches
|
|
26
|
+
|
|
27
|
+
| Kind | Example |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `missing-export` | `import { groupByKey } from "lodash"`, `const { makeClient } = require("x")` |
|
|
30
|
+
| `missing-member` | `app.listenAsync()`, `z.string().emial()` |
|
|
31
|
+
| `unknown-option` | `axios.get(url, { retries: 3 })` |
|
|
32
|
+
| `missing-package` | Importing a package that isn't installed. With `--online`, it also checks whether the package exists on npm at all. |
|
|
33
|
+
| `deprecated` (warning) | APIs marked `@deprecated` in your installed version, with the suggested replacement |
|
|
34
|
+
|
|
35
|
+
It works on **TypeScript and plain JavaScript** (`import` and `require`), with or without a `tsconfig.json`.
|
|
36
|
+
|
|
37
|
+
## Why not just run `tsc`?
|
|
38
|
+
|
|
39
|
+
- **JavaScript projects** don't have `tsc` checking them at all. realapi type-checks JS against the packages' own type definitions.
|
|
40
|
+
- **It reports only package API problems.** A `tsc` run on a large project can print hundreds of unrelated errors. realapi keeps only the ones where your code uses something a package doesn't provide.
|
|
41
|
+
- **Readable output.** Each problem names the package *and the installed version*, suggests the closest real name, and separates "not installed" from "doesn't exist".
|
|
42
|
+
- **It's honest about coverage.** Packages that ship no types are listed as *not verified*, rather than silently passing.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx realapi-check # check the whole project
|
|
48
|
+
npx realapi-check src/ scripts/ # check specific paths
|
|
49
|
+
npx realapi-check --json # machine-readable output for CI or AI agents
|
|
50
|
+
npx realapi-check --online # also look up missing packages on the npm registry
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Installed as a dev dependency (`npm i -D realapi-check`), the command is also available under the shorter name `realapi`.
|
|
54
|
+
|
|
55
|
+
| Option | Description |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `-p, --project <file>` | tsconfig/jsconfig to use (auto-detected by default) |
|
|
58
|
+
| `--json` | Print results as JSON |
|
|
59
|
+
| `--online` | Check missing packages against registry.npmjs.org |
|
|
60
|
+
| `--no-deprecated` | Don't report deprecated APIs |
|
|
61
|
+
| `--strict` | Exit with code 1 on warnings too |
|
|
62
|
+
|
|
63
|
+
**Exit codes:** `0` clean, `1` problems found, `2` usage or runtime error.
|
|
64
|
+
|
|
65
|
+
### Ignoring a line
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// realapi-ignore
|
|
69
|
+
client.addedAtRuntimeByAPlugin();
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### In CI
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
- run: npm ci
|
|
76
|
+
- run: npx realapi-check
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### With AI agents
|
|
80
|
+
|
|
81
|
+
Add this to your agent instructions (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`):
|
|
82
|
+
|
|
83
|
+
> After changing code, run `npx realapi-check --json` and fix every reported issue before finishing.
|
|
84
|
+
|
|
85
|
+
The JSON output includes the file, line, symbol, package, version and suggestion for each problem, so an agent can fix its own mistakes.
|
|
86
|
+
|
|
87
|
+
## Programmatic API
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { check, formatPretty } from "realapi-check";
|
|
91
|
+
|
|
92
|
+
const result = await check({ cwd: process.cwd(), paths: ["src"] });
|
|
93
|
+
console.log(formatPretty(result));
|
|
94
|
+
// result.issues, result.packagesChecked, result.unverifiable
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## How it works
|
|
98
|
+
|
|
99
|
+
realapi builds a TypeScript program over your project with `allowJs` and `checkJs` enabled, using your `tsconfig.json` settings when one exists. It takes the compiler's "does not exist" diagnostics and keeps only those whose target type is **declared inside `node_modules`**. That covers packages' own types, `@types/*`, and the Node built-ins. Your own code, TypeScript's standard library, and shapes derived from your data (such as `z.infer<...>`) are ignored, so it reports only real mismatches with package APIs. Module augmentation (`declare module "express" { ... }`) and tsconfig `paths` aliases are respected.
|
|
100
|
+
|
|
101
|
+
## Limitations
|
|
102
|
+
|
|
103
|
+
- Packages without type definitions can't be verified. They are listed in the summary.
|
|
104
|
+
- `.vue`, `.svelte` and `.astro` files aren't checked yet.
|
|
105
|
+
- In monorepos, run it once per package so each one uses its own `tsconfig.json`.
|
|
106
|
+
- Properties your own code assigns (`app.db = pool`, `req.user = decoded`) are treated as yours and never reported. Properties added by a *third-party* plugin that ships no types will be reported; add types for them or use `// realapi-ignore`.
|
|
107
|
+
- With a `tsconfig.json`, the files checked are the ones its `include`/`exclude` select. As with `tsc`, a `.js` file next to a `.ts` file with the same name is treated as build output and skipped.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
package/dist/analyzer.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { collectProjectDeclarations, collectSourceFiles, isDeclarationFile, normalize, } from "./files.js";
|
|
5
|
+
import { declaredDependencies, installedTypesPackages, isBuiltin, isInstalled, installedVersion, missingFromRegistry, packageNameOf, packageOfFile, usesPnp, } from "./packages.js";
|
|
6
|
+
// TypeScript diagnostics that mean "the code uses an API the types don't have".
|
|
7
|
+
const MISSING_EXPORT_CODES = new Set([2305, 2724, 2614, 2459, 2460]);
|
|
8
|
+
const MISSING_MEMBER_CODES = new Set([2339, 2551]);
|
|
9
|
+
const UNKNOWN_OPTION_CODES = new Set([2353, 2561]);
|
|
10
|
+
const DEPRECATED_CODES = new Set([6385, 6387]);
|
|
11
|
+
const IGNORE_COMMENT = /realapi-ignore\b/;
|
|
12
|
+
const tsLibDir = normalize(path.dirname(createRequire(import.meta.url).resolve("typescript")));
|
|
13
|
+
function loadCompilerOptions(cwd, project) {
|
|
14
|
+
const configPath = project
|
|
15
|
+
? path.resolve(cwd, project)
|
|
16
|
+
: ts.findConfigFile(cwd, ts.sys.fileExists, "tsconfig.json") ??
|
|
17
|
+
ts.findConfigFile(cwd, ts.sys.fileExists, "jsconfig.json");
|
|
18
|
+
let options = {
|
|
19
|
+
target: ts.ScriptTarget.ES2022,
|
|
20
|
+
module: ts.ModuleKind.Preserve,
|
|
21
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
22
|
+
jsx: ts.JsxEmit.Preserve,
|
|
23
|
+
strict: false,
|
|
24
|
+
};
|
|
25
|
+
let fileNames;
|
|
26
|
+
if (configPath && ts.sys.fileExists(configPath)) {
|
|
27
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
28
|
+
if (read.error) {
|
|
29
|
+
throw new Error(`Could not read ${configPath}: ${ts.flattenDiagnosticMessageText(read.error.messageText, "\n")}`);
|
|
30
|
+
}
|
|
31
|
+
// allowJs up front so the project's include patterns pick up JS files too.
|
|
32
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath), { allowJs: true }, configPath);
|
|
33
|
+
options = parsed.options;
|
|
34
|
+
fileNames = parsed.fileNames.map(normalize);
|
|
35
|
+
}
|
|
36
|
+
else if (project) {
|
|
37
|
+
throw new Error(`Project file not found: ${project}`);
|
|
38
|
+
}
|
|
39
|
+
// TypeScript 6 no longer loads every @types package by default. Restore
|
|
40
|
+
// the long-standing behaviour so Node built-ins and globals resolve.
|
|
41
|
+
if (options.types === undefined)
|
|
42
|
+
options.types = installedTypesPackages(cwd);
|
|
43
|
+
return {
|
|
44
|
+
options: {
|
|
45
|
+
...options,
|
|
46
|
+
allowJs: true,
|
|
47
|
+
checkJs: true,
|
|
48
|
+
noEmit: true,
|
|
49
|
+
skipLibCheck: true,
|
|
50
|
+
esModuleInterop: true,
|
|
51
|
+
allowSyntheticDefaultImports: true,
|
|
52
|
+
resolveJsonModule: true,
|
|
53
|
+
incremental: false,
|
|
54
|
+
composite: false,
|
|
55
|
+
declaration: false,
|
|
56
|
+
emitDeclarationOnly: false,
|
|
57
|
+
maxNodeModuleJsDepth: 0,
|
|
58
|
+
ignoreDeprecations: "6.0",
|
|
59
|
+
},
|
|
60
|
+
fileNames,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Deepest node whose span contains `pos`. */
|
|
64
|
+
function nodeAt(sf, pos) {
|
|
65
|
+
let found = sf;
|
|
66
|
+
const visit = (node) => {
|
|
67
|
+
if (pos >= node.getStart(sf) && pos < node.getEnd()) {
|
|
68
|
+
found = node;
|
|
69
|
+
ts.forEachChild(node, visit);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
ts.forEachChild(sf, visit);
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
function flatten(message) {
|
|
76
|
+
return ts.flattenDiagnosticMessageText(message, "\n");
|
|
77
|
+
}
|
|
78
|
+
function suggestionFrom(message) {
|
|
79
|
+
return /Did you mean '([^']+)'\?/.exec(message)?.[1];
|
|
80
|
+
}
|
|
81
|
+
export async function check(opts = {}) {
|
|
82
|
+
const cwd = path.resolve(opts.cwd ?? process.cwd());
|
|
83
|
+
const reportDeprecated = opts.deprecated ?? true;
|
|
84
|
+
const { options, fileNames } = loadCompilerOptions(cwd, opts.project);
|
|
85
|
+
const explicitPaths = opts.paths?.length ? opts.paths : undefined;
|
|
86
|
+
// With a tsconfig, its include/exclude decide the project (which keeps out
|
|
87
|
+
// build output and fixtures). Without one, or for explicit paths, walk.
|
|
88
|
+
const candidates = explicitPaths || !fileNames ? collectSourceFiles(cwd, explicitPaths ?? ["."]) : fileNames;
|
|
89
|
+
const sources = candidates.filter((f) => !isDeclarationFile(f));
|
|
90
|
+
const declarations = fileNames ? fileNames.filter(isDeclarationFile) : collectProjectDeclarations(cwd);
|
|
91
|
+
const rootNames = [...new Set([...sources, ...declarations])];
|
|
92
|
+
// A language service (rather than a bare Program) is needed for suggestion
|
|
93
|
+
// diagnostics, which is where TypeScript reports `@deprecated` usage.
|
|
94
|
+
const host = {
|
|
95
|
+
getCompilationSettings: () => options,
|
|
96
|
+
getScriptFileNames: () => rootNames,
|
|
97
|
+
getScriptVersion: () => "0",
|
|
98
|
+
getScriptSnapshot: (file) => {
|
|
99
|
+
const text = ts.sys.readFile(file);
|
|
100
|
+
return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
|
|
101
|
+
},
|
|
102
|
+
getCurrentDirectory: () => cwd,
|
|
103
|
+
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
|
|
104
|
+
fileExists: ts.sys.fileExists,
|
|
105
|
+
readFile: ts.sys.readFile,
|
|
106
|
+
readDirectory: ts.sys.readDirectory,
|
|
107
|
+
directoryExists: ts.sys.directoryExists,
|
|
108
|
+
getDirectories: ts.sys.getDirectories,
|
|
109
|
+
realpath: ts.sys.realpath,
|
|
110
|
+
};
|
|
111
|
+
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
112
|
+
const program = service.getProgram();
|
|
113
|
+
if (!program)
|
|
114
|
+
throw new Error("TypeScript could not create a program for this project");
|
|
115
|
+
const checker = program.getTypeChecker();
|
|
116
|
+
const isExternalFile = (sf) => {
|
|
117
|
+
if (program.isSourceFileDefaultLibrary(sf))
|
|
118
|
+
return false;
|
|
119
|
+
const name = sf.fileName;
|
|
120
|
+
if (name.startsWith(tsLibDir + "/"))
|
|
121
|
+
return false;
|
|
122
|
+
return program.isSourceFileFromExternalLibrary(sf) || name.includes("/node_modules/");
|
|
123
|
+
};
|
|
124
|
+
const isAugmentation = (decl) => !!ts.findAncestor(decl, (n) => ts.isModuleDeclaration(n) && ts.isStringLiteral(n.name));
|
|
125
|
+
/**
|
|
126
|
+
* The package that declares `symbol`. Project declarations are allowed only
|
|
127
|
+
* when they augment a package (`declare module "pkg" { ... }`); a symbol
|
|
128
|
+
* that the project itself defines, or one from TypeScript's libs, has no
|
|
129
|
+
* external owner.
|
|
130
|
+
*/
|
|
131
|
+
const externalOwner = (symbol) => {
|
|
132
|
+
const decls = symbol?.getDeclarations();
|
|
133
|
+
if (!decls?.length)
|
|
134
|
+
return undefined;
|
|
135
|
+
let owner;
|
|
136
|
+
for (const decl of decls) {
|
|
137
|
+
// Mapped types (z.infer<...>, Pick<...>) describe the user's own data shapes.
|
|
138
|
+
if (ts.isMappedTypeNode(decl))
|
|
139
|
+
return undefined;
|
|
140
|
+
const sf = decl.getSourceFile();
|
|
141
|
+
if (isExternalFile(sf))
|
|
142
|
+
owner ??= packageOfFile(sf.fileName);
|
|
143
|
+
else if (!isAugmentation(decl))
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
return owner;
|
|
147
|
+
};
|
|
148
|
+
/** Owner of a type: every constituent that has a symbol must be external. */
|
|
149
|
+
const externalOwnerOfType = (type) => {
|
|
150
|
+
const parts = type.isUnionOrIntersection() ? type.types : [type];
|
|
151
|
+
let owner;
|
|
152
|
+
let any = false;
|
|
153
|
+
for (const part of parts) {
|
|
154
|
+
const symbol = part.aliasSymbol ?? part.getSymbol();
|
|
155
|
+
if (!symbol)
|
|
156
|
+
continue;
|
|
157
|
+
const o = externalOwner(symbol);
|
|
158
|
+
if (!o)
|
|
159
|
+
return undefined;
|
|
160
|
+
owner ??= o;
|
|
161
|
+
any = true;
|
|
162
|
+
}
|
|
163
|
+
return any ? owner : undefined;
|
|
164
|
+
};
|
|
165
|
+
const isModuleType = (type) => {
|
|
166
|
+
const decls = type.getSymbol()?.getDeclarations() ?? [];
|
|
167
|
+
return decls.length > 0 && decls.every((d) => ts.isSourceFile(d) || ts.isModuleDeclaration(d));
|
|
168
|
+
};
|
|
169
|
+
const moduleSpecifierOwner = (specifier) => {
|
|
170
|
+
if (!ts.isStringLiteralLike(specifier) || !packageNameOf(specifier.text))
|
|
171
|
+
return undefined;
|
|
172
|
+
return externalOwner(checker.getSymbolAtLocation(specifier));
|
|
173
|
+
};
|
|
174
|
+
/** Work out which external API a "does not exist" diagnostic is about. */
|
|
175
|
+
const locate = (node, code) => {
|
|
176
|
+
const parent = node.parent;
|
|
177
|
+
if (!parent)
|
|
178
|
+
return undefined;
|
|
179
|
+
// import { x } from "pkg" / export { x } from "pkg"
|
|
180
|
+
if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent) || ts.isImportClause(parent)) {
|
|
181
|
+
const decl = ts.findAncestor(parent, (n) => ts.isImportDeclaration(n) || ts.isExportDeclaration(n));
|
|
182
|
+
const spec = decl?.moduleSpecifier;
|
|
183
|
+
if (!spec || !ts.isStringLiteralLike(spec))
|
|
184
|
+
return undefined;
|
|
185
|
+
const owner = moduleSpecifierOwner(spec);
|
|
186
|
+
return owner && { kind: "missing-export", owner, ownerLabel: `"${spec.text}"` };
|
|
187
|
+
}
|
|
188
|
+
let ownerType;
|
|
189
|
+
let kind = "missing-member";
|
|
190
|
+
if (ts.isPropertyAccessExpression(parent) && parent.name === node) {
|
|
191
|
+
ownerType = checker.getTypeAtLocation(parent.expression);
|
|
192
|
+
}
|
|
193
|
+
else if (ts.isQualifiedName(parent) && parent.right === node) {
|
|
194
|
+
ownerType = checker.getTypeAtLocation(parent.left);
|
|
195
|
+
if (!externalOwnerOfType(ownerType)) {
|
|
196
|
+
const owner = externalOwner(checker.getSymbolAtLocation(parent.left));
|
|
197
|
+
const sym = checker.getSymbolAtLocation(parent.left);
|
|
198
|
+
if (owner && sym)
|
|
199
|
+
return { kind: "missing-export", owner, ownerLabel: sym.getName() };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else if (ts.isBindingElement(parent) && ts.isObjectBindingPattern(parent.parent)) {
|
|
203
|
+
const holder = parent.parent.parent;
|
|
204
|
+
ownerType =
|
|
205
|
+
ts.isVariableDeclaration(holder) && holder.initializer
|
|
206
|
+
? checker.getTypeAtLocation(holder.initializer)
|
|
207
|
+
: checker.getTypeAtLocation(parent.parent);
|
|
208
|
+
}
|
|
209
|
+
else if (UNKNOWN_OPTION_CODES.has(code) &&
|
|
210
|
+
(ts.isPropertyAssignment(parent) || ts.isShorthandPropertyAssignment(parent) || ts.isMethodDeclaration(parent)) &&
|
|
211
|
+
ts.isObjectLiteralExpression(parent.parent)) {
|
|
212
|
+
kind = "unknown-option";
|
|
213
|
+
ownerType = checker.getContextualType(parent.parent);
|
|
214
|
+
}
|
|
215
|
+
if (!ownerType)
|
|
216
|
+
return undefined;
|
|
217
|
+
ownerType = checker.getNonNullableType(ownerType);
|
|
218
|
+
const owner = externalOwnerOfType(ownerType);
|
|
219
|
+
if (!owner)
|
|
220
|
+
return undefined;
|
|
221
|
+
if (kind === "missing-member" && isModuleType(ownerType))
|
|
222
|
+
kind = "missing-export";
|
|
223
|
+
return { kind, owner, ownerLabel: checker.typeToString(ownerType) };
|
|
224
|
+
};
|
|
225
|
+
const issues = [];
|
|
226
|
+
const checkedPackages = new Map();
|
|
227
|
+
const unverifiable = new Map();
|
|
228
|
+
const missingSpecifiers = [];
|
|
229
|
+
const declared = declaredDependencies(cwd);
|
|
230
|
+
const pnp = usesPnp(cwd);
|
|
231
|
+
const hasNodeTypes = isInstalled("@types/node", cwd);
|
|
232
|
+
const lineInfo = (sf, pos) => {
|
|
233
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(pos);
|
|
234
|
+
const lines = sf.text.split(/\r?\n/);
|
|
235
|
+
const text = lines[line] ?? "";
|
|
236
|
+
const prev = lines[line - 1] ?? "";
|
|
237
|
+
return {
|
|
238
|
+
line: line + 1,
|
|
239
|
+
column: character + 1,
|
|
240
|
+
sourceLine: text.trim(),
|
|
241
|
+
ignored: IGNORE_COMMENT.test(text) || /^\s*(\/\/|\/\*|\*).*realapi-ignore\b/.test(prev),
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
const relative = (sf) => path.relative(cwd, sf.fileName).replace(/\\/g, "/");
|
|
245
|
+
const push = (sf, pos, issue) => {
|
|
246
|
+
const info = lineInfo(sf, pos);
|
|
247
|
+
if (info.ignored)
|
|
248
|
+
return;
|
|
249
|
+
issues.push({ ...issue, file: relative(sf), line: info.line, column: info.column, sourceLine: info.sourceLine });
|
|
250
|
+
};
|
|
251
|
+
const sourceSet = new Set(sources);
|
|
252
|
+
const userFiles = program.getSourceFiles().filter((sf) => sourceSet.has(sf.fileName));
|
|
253
|
+
// 1. Imports: record what was verified, what couldn't be, and what's missing.
|
|
254
|
+
const imported = new Map();
|
|
255
|
+
for (const sf of userFiles) {
|
|
256
|
+
for (const spec of moduleSpecifiers(sf)) {
|
|
257
|
+
const name = packageNameOf(spec.text);
|
|
258
|
+
if (!name)
|
|
259
|
+
continue;
|
|
260
|
+
const symbol = checker.getSymbolAtLocation(spec);
|
|
261
|
+
const owner = externalOwner(symbol);
|
|
262
|
+
if (owner) {
|
|
263
|
+
imported.set(owner.rawName, owner);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (symbol)
|
|
267
|
+
continue; // Resolved to project code or an ambient declaration.
|
|
268
|
+
if (isBuiltin(spec.text)) {
|
|
269
|
+
if (!hasNodeTypes)
|
|
270
|
+
unverifiable.set("node", { package: "node", reason: "no-node-types" });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (isAliased(spec.text, options))
|
|
274
|
+
continue;
|
|
275
|
+
if (isInstalled(name, path.dirname(sf.fileName)) || pnp) {
|
|
276
|
+
unverifiable.set(name, { package: name, reason: "no-types" });
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
missingSpecifiers.push({ name, declared: declared.has(name), sf, node: spec });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The package to name in messages. Types often live in an internal package
|
|
285
|
+
* (`Express` is declared in `@types/express-serve-static-core`), so credit
|
|
286
|
+
* the imported package that depends on it. For `@types/x`, report the
|
|
287
|
+
* installed runtime version of `x`, which is the version the user knows.
|
|
288
|
+
*/
|
|
289
|
+
const display = (owner) => {
|
|
290
|
+
let shown = owner;
|
|
291
|
+
if (!imported.has(owner.rawName)) {
|
|
292
|
+
for (const pkg of imported.values()) {
|
|
293
|
+
if (pkg.dependencies.includes(owner.rawName)) {
|
|
294
|
+
shown = pkg;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Built-in typings version isn't Node's version; say where it came from.
|
|
300
|
+
if (shown.rawName === "@types/node")
|
|
301
|
+
return { name: "@types/node", version: shown.version };
|
|
302
|
+
const version = shown.rawName.startsWith("@types/")
|
|
303
|
+
? installedVersion(shown.name, cwd) ?? shown.version
|
|
304
|
+
: shown.version;
|
|
305
|
+
return { name: shown.name, version };
|
|
306
|
+
};
|
|
307
|
+
const versioned = (p) => {
|
|
308
|
+
const d = display(p);
|
|
309
|
+
return d.version ? `${d.name}@${d.version}` : d.name;
|
|
310
|
+
};
|
|
311
|
+
const displayFields = (p) => {
|
|
312
|
+
const d = display(p);
|
|
313
|
+
return { package: d.name, version: d.version };
|
|
314
|
+
};
|
|
315
|
+
for (const pkg of imported.values()) {
|
|
316
|
+
const d = display(pkg);
|
|
317
|
+
checkedPackages.set(d.name, d.version);
|
|
318
|
+
}
|
|
319
|
+
// 2. APIs that don't exist in the installed types.
|
|
320
|
+
const found = [];
|
|
321
|
+
// Properties the project assigns onto package objects (`app.db = pool`,
|
|
322
|
+
// `req.user = decoded` in JS middleware). Those are the user's own
|
|
323
|
+
// additions, so both the write and every later read are left alone.
|
|
324
|
+
const assigned = new Set();
|
|
325
|
+
const memberKey = (located, name) => `${located.ownerLabel}#${name}`;
|
|
326
|
+
for (const sf of userFiles) {
|
|
327
|
+
for (const diag of program.getSemanticDiagnostics(sf)) {
|
|
328
|
+
const code = diag.code;
|
|
329
|
+
if (!MISSING_EXPORT_CODES.has(code) && !MISSING_MEMBER_CODES.has(code) && !UNKNOWN_OPTION_CODES.has(code))
|
|
330
|
+
continue;
|
|
331
|
+
if (diag.start === undefined)
|
|
332
|
+
continue;
|
|
333
|
+
const node = nodeAt(sf, diag.start);
|
|
334
|
+
const located = locate(node, code);
|
|
335
|
+
if (!located)
|
|
336
|
+
continue;
|
|
337
|
+
const name = ts.isIdentifier(node) || ts.isStringLiteralLike(node) || ts.isPrivateIdentifier(node) ? node.text : node.getText(sf);
|
|
338
|
+
if (isAssignmentTarget(node))
|
|
339
|
+
assigned.add(memberKey(located, name));
|
|
340
|
+
found.push({ sf, start: diag.start, message: flatten(diag.messageText), located, name });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const { sf, start, message: tsMessage, located, name } of found) {
|
|
344
|
+
if (located.kind === "missing-member" && assigned.has(memberKey(located, name)))
|
|
345
|
+
continue;
|
|
346
|
+
const suggestion = suggestionFrom(tsMessage);
|
|
347
|
+
const where = versioned(located.owner);
|
|
348
|
+
let message;
|
|
349
|
+
if (located.kind === "missing-export") {
|
|
350
|
+
message = `'${name}' is not exported by ${where}`;
|
|
351
|
+
}
|
|
352
|
+
else if (located.kind === "unknown-option") {
|
|
353
|
+
message = `'${name}' is not a known option of ${located.ownerLabel} (${where})`;
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
message = `'${name}' does not exist on ${located.ownerLabel} (${where})`;
|
|
357
|
+
}
|
|
358
|
+
if (suggestion)
|
|
359
|
+
message += `. Did you mean '${suggestion}'?`;
|
|
360
|
+
push(sf, start, {
|
|
361
|
+
kind: located.kind,
|
|
362
|
+
severity: "error",
|
|
363
|
+
message,
|
|
364
|
+
symbol: name,
|
|
365
|
+
...displayFields(located.owner),
|
|
366
|
+
suggestion,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
// 3. APIs that exist but are deprecated in the installed version.
|
|
370
|
+
for (const sf of userFiles) {
|
|
371
|
+
if (reportDeprecated) {
|
|
372
|
+
for (const diag of service.getSuggestionDiagnostics(sf.fileName)) {
|
|
373
|
+
if (!DEPRECATED_CODES.has(diag.code) || diag.start === undefined)
|
|
374
|
+
continue;
|
|
375
|
+
const node = nodeAt(sf, diag.start);
|
|
376
|
+
let symbol = checker.getSymbolAtLocation(node);
|
|
377
|
+
if (symbol && symbol.flags & ts.SymbolFlags.Alias)
|
|
378
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
379
|
+
const owner = externalOwner(symbol);
|
|
380
|
+
if (!owner || !symbol)
|
|
381
|
+
continue;
|
|
382
|
+
let tags = symbol.getJsDocTags(checker);
|
|
383
|
+
const call = node.parent && ts.findAncestor(node.parent, (n) => ts.isCallExpression(n) || ts.isNewExpression(n));
|
|
384
|
+
if (diag.code === 6387 && call && (ts.isCallExpression(call) || ts.isNewExpression(call))) {
|
|
385
|
+
tags = checker.getResolvedSignature(call)?.getJsDocTags() ?? tags;
|
|
386
|
+
}
|
|
387
|
+
const note = tags
|
|
388
|
+
.find((t) => t.name === "deprecated")
|
|
389
|
+
?.text?.map((p) => p.text)
|
|
390
|
+
.join("")
|
|
391
|
+
.replace(/\s+/g, " ")
|
|
392
|
+
.trim();
|
|
393
|
+
const name = symbol.getName();
|
|
394
|
+
push(sf, diag.start, {
|
|
395
|
+
kind: "deprecated",
|
|
396
|
+
severity: "warning",
|
|
397
|
+
message: `'${name}' is deprecated in ${versioned(owner)}${note ? `: ${note}` : ""}`,
|
|
398
|
+
symbol: name,
|
|
399
|
+
...displayFields(owner),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// 4. Packages that aren't installed at all.
|
|
405
|
+
const registryCache = new Map();
|
|
406
|
+
for (const miss of missingSpecifiers) {
|
|
407
|
+
let message;
|
|
408
|
+
if (miss.declared) {
|
|
409
|
+
message = `'${miss.name}' is listed in package.json but not installed. Run your package manager's install.`;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
message = `'${miss.name}' is not installed and not listed in package.json`;
|
|
413
|
+
if (opts.online) {
|
|
414
|
+
if (!registryCache.has(miss.name))
|
|
415
|
+
registryCache.set(miss.name, missingFromRegistry(miss.name));
|
|
416
|
+
const absent = await registryCache.get(miss.name);
|
|
417
|
+
if (absent === true)
|
|
418
|
+
message = `'${miss.name}' does not exist on npm. It was probably invented; do not install it blindly`;
|
|
419
|
+
else if (absent === false)
|
|
420
|
+
message += `. It exists on npm; check it's the package you meant before installing`;
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
message += `. Check it's a real package before installing`;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
push(miss.sf, miss.node.getStart(miss.sf), {
|
|
427
|
+
kind: "missing-package",
|
|
428
|
+
severity: miss.declared ? "warning" : "error",
|
|
429
|
+
message,
|
|
430
|
+
symbol: miss.name,
|
|
431
|
+
package: miss.name,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
issues.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
|
|
435
|
+
return {
|
|
436
|
+
issues,
|
|
437
|
+
filesChecked: sources.length,
|
|
438
|
+
packagesChecked: [...checkedPackages].map(([name, version]) => ({ name, version })).sort((a, b) => a.name.localeCompare(b.name)),
|
|
439
|
+
unverifiable: [...unverifiable.values()].sort((a, b) => a.package.localeCompare(b.package)),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/** Whether `node` is the property name being written in `obj.prop = value` (or `+=`, `??=`, ...). */
|
|
443
|
+
function isAssignmentTarget(node) {
|
|
444
|
+
const access = node.parent;
|
|
445
|
+
if (!access || !ts.isPropertyAccessExpression(access) || access.name !== node)
|
|
446
|
+
return false;
|
|
447
|
+
const assignment = access.parent;
|
|
448
|
+
return (!!assignment &&
|
|
449
|
+
ts.isBinaryExpression(assignment) &&
|
|
450
|
+
assignment.left === access &&
|
|
451
|
+
assignment.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
452
|
+
assignment.operatorToken.kind <= ts.SyntaxKind.LastAssignment);
|
|
453
|
+
}
|
|
454
|
+
/** Whether `specifier` matches a tsconfig `paths` alias. */
|
|
455
|
+
function isAliased(specifier, options) {
|
|
456
|
+
for (const pattern of Object.keys(options.paths ?? {})) {
|
|
457
|
+
const star = pattern.indexOf("*");
|
|
458
|
+
if (star === -1 ? specifier === pattern : specifier.startsWith(pattern.slice(0, star)) && specifier.endsWith(pattern.slice(star + 1))) {
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
/** Every static module specifier in a file: import/export/require/import(). */
|
|
465
|
+
function moduleSpecifiers(sf) {
|
|
466
|
+
const out = [];
|
|
467
|
+
const visit = (node) => {
|
|
468
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
|
469
|
+
// `import type` from an untyped package is still worth tracking; keep all.
|
|
470
|
+
out.push(node.moduleSpecifier);
|
|
471
|
+
}
|
|
472
|
+
else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
|
473
|
+
const expr = node.moduleReference.expression;
|
|
474
|
+
if (ts.isStringLiteralLike(expr))
|
|
475
|
+
out.push(expr);
|
|
476
|
+
}
|
|
477
|
+
else if (ts.isCallExpression(node) && node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0])) {
|
|
478
|
+
const callee = node.expression;
|
|
479
|
+
const isRequire = ts.isIdentifier(callee) && callee.text === "require";
|
|
480
|
+
const isDynamicImport = callee.kind === ts.SyntaxKind.ImportKeyword;
|
|
481
|
+
if (isRequire || isDynamicImport)
|
|
482
|
+
out.push(node.arguments[0]);
|
|
483
|
+
}
|
|
484
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal)) {
|
|
485
|
+
out.push(node.argument.literal);
|
|
486
|
+
}
|
|
487
|
+
ts.forEachChild(node, visit);
|
|
488
|
+
};
|
|
489
|
+
visit(sf);
|
|
490
|
+
return out;
|
|
491
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { check } from "./analyzer.js";
|
|
4
|
+
import { formatJson, formatPretty } from "./report.js";
|
|
5
|
+
const HELP = `realapi: catch AI-hallucinated imports, methods and options
|
|
6
|
+
|
|
7
|
+
Usage
|
|
8
|
+
npx realapi-check [paths...] [options]
|
|
9
|
+
|
|
10
|
+
Checks your code against the package versions installed in node_modules
|
|
11
|
+
and reports APIs that don't exist there. Works on TypeScript and JavaScript.
|
|
12
|
+
|
|
13
|
+
Options
|
|
14
|
+
-p, --project <file> tsconfig/jsconfig to use (auto-detected by default)
|
|
15
|
+
--json Print results as JSON (for CI and AI agents)
|
|
16
|
+
--online Check missing packages against the npm registry
|
|
17
|
+
--no-deprecated Don't report deprecated APIs
|
|
18
|
+
--strict Exit with code 1 on warnings too
|
|
19
|
+
-h, --help Show this help
|
|
20
|
+
-v, --version Show version
|
|
21
|
+
|
|
22
|
+
Silence a line with a // realapi-ignore comment on it or on the line above.
|
|
23
|
+
|
|
24
|
+
Exit codes: 0 clean, 1 problems found, 2 usage or runtime error.`;
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const args = { paths: [], project: undefined, json: false, online: false, deprecated: true, strict: false };
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
switch (a) {
|
|
30
|
+
case "-h":
|
|
31
|
+
case "--help":
|
|
32
|
+
console.log(HELP);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
case "-v":
|
|
35
|
+
case "--version": {
|
|
36
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
37
|
+
console.log(pkg.version);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
case "-p":
|
|
41
|
+
case "--project":
|
|
42
|
+
args.project = argv[++i];
|
|
43
|
+
if (!args.project)
|
|
44
|
+
throw new Error(`${a} needs a file path`);
|
|
45
|
+
break;
|
|
46
|
+
case "--json":
|
|
47
|
+
args.json = true;
|
|
48
|
+
break;
|
|
49
|
+
case "--online":
|
|
50
|
+
args.online = true;
|
|
51
|
+
break;
|
|
52
|
+
case "--no-deprecated":
|
|
53
|
+
args.deprecated = false;
|
|
54
|
+
break;
|
|
55
|
+
case "--strict":
|
|
56
|
+
args.strict = true;
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
if (a.startsWith("-"))
|
|
60
|
+
throw new Error(`Unknown option: ${a}`);
|
|
61
|
+
args.paths.push(a);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return args;
|
|
65
|
+
}
|
|
66
|
+
async function main() {
|
|
67
|
+
let args;
|
|
68
|
+
try {
|
|
69
|
+
args = parseArgs(process.argv.slice(2));
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.error(`realapi: ${err.message}\nRun 'realapi --help' for usage.`);
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const result = await check({
|
|
77
|
+
paths: args.paths,
|
|
78
|
+
project: args.project,
|
|
79
|
+
deprecated: args.deprecated,
|
|
80
|
+
online: args.online,
|
|
81
|
+
});
|
|
82
|
+
console.log(args.json ? formatJson(result) : formatPretty(result));
|
|
83
|
+
const failing = result.issues.some((i) => i.severity === "error" || args.strict);
|
|
84
|
+
// exitCode rather than exit(): piped stdout is async on some platforms and
|
|
85
|
+
// exiting immediately can truncate large --json output.
|
|
86
|
+
process.exitCode = failing ? 1 : 0;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.error(`realapi: ${err.message}`);
|
|
90
|
+
process.exitCode = 2;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
main();
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function isDeclarationFile(file: string): boolean;
|
|
2
|
+
/** Absolute, forward-slash path, matching how TypeScript names source files. */
|
|
3
|
+
export declare function normalize(file: string): string;
|
|
4
|
+
/** Source files under the given paths (files or directories). */
|
|
5
|
+
export declare function collectSourceFiles(cwd: string, paths: string[]): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Project `.d.ts` files anywhere under cwd. These carry module augmentation
|
|
8
|
+
* (`declare module "express" { ... }`) and env typings (`vite-env.d.ts`), so
|
|
9
|
+
* they must be part of the program even when only `src/` is being checked.
|
|
10
|
+
*/
|
|
11
|
+
export declare function collectProjectDeclarations(cwd: string): string[];
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const SOURCE_EXT = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
4
|
+
const DECLARATION = /\.d\.[cm]?ts$/;
|
|
5
|
+
const IGNORED_DIRS = new Set([
|
|
6
|
+
"node_modules",
|
|
7
|
+
".git",
|
|
8
|
+
"dist",
|
|
9
|
+
"build",
|
|
10
|
+
"out",
|
|
11
|
+
"coverage",
|
|
12
|
+
".next",
|
|
13
|
+
".nuxt",
|
|
14
|
+
".svelte-kit",
|
|
15
|
+
".output",
|
|
16
|
+
".turbo",
|
|
17
|
+
".vercel",
|
|
18
|
+
".cache",
|
|
19
|
+
".yarn",
|
|
20
|
+
]);
|
|
21
|
+
export function isDeclarationFile(file) {
|
|
22
|
+
return DECLARATION.test(file);
|
|
23
|
+
}
|
|
24
|
+
/** Absolute, forward-slash path, matching how TypeScript names source files. */
|
|
25
|
+
export function normalize(file) {
|
|
26
|
+
return path.resolve(file).replace(/\\/g, "/");
|
|
27
|
+
}
|
|
28
|
+
function walk(dir, out, filter) {
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const full = path.join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
if (!IGNORED_DIRS.has(entry.name))
|
|
40
|
+
walk(full, out, filter);
|
|
41
|
+
}
|
|
42
|
+
else if (entry.isFile() && filter(entry.name)) {
|
|
43
|
+
out.add(normalize(full));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function isSource(name) {
|
|
48
|
+
return SOURCE_EXT.test(name) && !/\.min\.[cm]?js$/.test(name);
|
|
49
|
+
}
|
|
50
|
+
/** Source files under the given paths (files or directories). */
|
|
51
|
+
export function collectSourceFiles(cwd, paths) {
|
|
52
|
+
const out = new Set();
|
|
53
|
+
for (const p of paths) {
|
|
54
|
+
const full = path.resolve(cwd, p);
|
|
55
|
+
let stat;
|
|
56
|
+
try {
|
|
57
|
+
stat = fs.statSync(full);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new Error(`Path not found: ${p}`);
|
|
61
|
+
}
|
|
62
|
+
if (stat.isDirectory())
|
|
63
|
+
walk(full, out, isSource);
|
|
64
|
+
else
|
|
65
|
+
out.add(normalize(full));
|
|
66
|
+
}
|
|
67
|
+
return [...out];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Project `.d.ts` files anywhere under cwd. These carry module augmentation
|
|
71
|
+
* (`declare module "express" { ... }`) and env typings (`vite-env.d.ts`), so
|
|
72
|
+
* they must be part of the program even when only `src/` is being checked.
|
|
73
|
+
*/
|
|
74
|
+
export function collectProjectDeclarations(cwd) {
|
|
75
|
+
const out = new Set();
|
|
76
|
+
walk(cwd, out, isDeclarationFile);
|
|
77
|
+
return [...out];
|
|
78
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare function isBuiltin(specifier: string): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Package name for a bare specifier, or undefined when the specifier is not a
|
|
4
|
+
* plain npm package reference (relative paths, `#imports`, `@/` or `~/`
|
|
5
|
+
* aliases, `virtual:`-style schemes).
|
|
6
|
+
*/
|
|
7
|
+
export declare function packageNameOf(specifier: string): string | undefined;
|
|
8
|
+
/** `@types/foo` → `foo`, `@types/babel__core` → `@babel/core`, `@types/node` → `node`. */
|
|
9
|
+
export declare function fromTypesPackage(name: string): string;
|
|
10
|
+
export interface PackageInfo {
|
|
11
|
+
/** User-facing name: `@types/lodash` is reported as `lodash`. */
|
|
12
|
+
name: string;
|
|
13
|
+
version?: string;
|
|
14
|
+
/** Name as published, e.g. `@types/lodash`. */
|
|
15
|
+
rawName: string;
|
|
16
|
+
/** Package directory, forward slashes. */
|
|
17
|
+
root: string;
|
|
18
|
+
/** Names of this package's own dependencies. */
|
|
19
|
+
dependencies: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The package a node_modules file belongs to. Uses the last `node_modules/`
|
|
23
|
+
* segment so pnpm's `.pnpm/pkg@1.0.0/node_modules/pkg/...` layout works.
|
|
24
|
+
*/
|
|
25
|
+
export declare function packageOfFile(fileName: string): PackageInfo | undefined;
|
|
26
|
+
/** Version of `name` installed in a node_modules directory visible from `fromDir`. */
|
|
27
|
+
export declare function installedVersion(name: string, fromDir: string): string | undefined;
|
|
28
|
+
/** Whether `name` is installed in a node_modules directory visible from `fromDir`. */
|
|
29
|
+
export declare function isInstalled(name: string, fromDir: string): boolean;
|
|
30
|
+
/** Yarn Plug'n'Play has no node_modules, so on-disk presence can't be checked. */
|
|
31
|
+
export declare function usesPnp(cwd: string): boolean;
|
|
32
|
+
/** Every dependency name declared in the nearest package.json. */
|
|
33
|
+
export declare function declaredDependencies(cwd: string): Set<string>;
|
|
34
|
+
/** Names of every installed `@types/*` package visible from cwd, as `types` entries. */
|
|
35
|
+
export declare function installedTypesPackages(cwd: string): string[];
|
|
36
|
+
/** Returns true when the npm registry has no package with this name. */
|
|
37
|
+
export declare function missingFromRegistry(name: string): Promise<boolean | undefined>;
|
package/dist/packages.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { builtinModules } from "node:module";
|
|
4
|
+
const BUILTINS = new Set(builtinModules);
|
|
5
|
+
export function isBuiltin(specifier) {
|
|
6
|
+
if (specifier.startsWith("node:"))
|
|
7
|
+
return true;
|
|
8
|
+
return BUILTINS.has(specifier) || BUILTINS.has(specifier.split("/")[0]);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Package name for a bare specifier, or undefined when the specifier is not a
|
|
12
|
+
* plain npm package reference (relative paths, `#imports`, `@/` or `~/`
|
|
13
|
+
* aliases, `virtual:`-style schemes).
|
|
14
|
+
*/
|
|
15
|
+
export function packageNameOf(specifier) {
|
|
16
|
+
if (specifier.startsWith("node:"))
|
|
17
|
+
return "node";
|
|
18
|
+
if (!specifier || specifier.startsWith(".") || specifier.startsWith("/"))
|
|
19
|
+
return undefined;
|
|
20
|
+
if (specifier.startsWith("#") || specifier.startsWith("~") || specifier.startsWith("@/"))
|
|
21
|
+
return undefined;
|
|
22
|
+
if (/^[a-zA-Z][\w+.-]*:/.test(specifier))
|
|
23
|
+
return undefined;
|
|
24
|
+
const parts = specifier.split("/");
|
|
25
|
+
if (specifier.startsWith("@")) {
|
|
26
|
+
if (parts.length < 2 || !parts[1])
|
|
27
|
+
return undefined;
|
|
28
|
+
return `${parts[0]}/${parts[1]}`;
|
|
29
|
+
}
|
|
30
|
+
return parts[0];
|
|
31
|
+
}
|
|
32
|
+
/** `@types/foo` → `foo`, `@types/babel__core` → `@babel/core`, `@types/node` → `node`. */
|
|
33
|
+
export function fromTypesPackage(name) {
|
|
34
|
+
if (!name.startsWith("@types/"))
|
|
35
|
+
return name;
|
|
36
|
+
const bare = name.slice("@types/".length);
|
|
37
|
+
return bare.includes("__") ? `@${bare.replace("__", "/")}` : bare;
|
|
38
|
+
}
|
|
39
|
+
const infoCache = new Map();
|
|
40
|
+
/**
|
|
41
|
+
* The package a node_modules file belongs to. Uses the last `node_modules/`
|
|
42
|
+
* segment so pnpm's `.pnpm/pkg@1.0.0/node_modules/pkg/...` layout works.
|
|
43
|
+
*/
|
|
44
|
+
export function packageOfFile(fileName) {
|
|
45
|
+
const marker = "/node_modules/";
|
|
46
|
+
const idx = fileName.lastIndexOf(marker);
|
|
47
|
+
if (idx === -1)
|
|
48
|
+
return undefined;
|
|
49
|
+
const rest = fileName.slice(idx + marker.length).split("/");
|
|
50
|
+
const rawName = rest[0].startsWith("@") ? `${rest[0]}/${rest[1]}` : rest[0];
|
|
51
|
+
const root = fileName.slice(0, idx + marker.length) + rawName;
|
|
52
|
+
if (infoCache.has(root))
|
|
53
|
+
return infoCache.get(root);
|
|
54
|
+
let info = { name: fromTypesPackage(rawName), rawName, root, dependencies: [] };
|
|
55
|
+
try {
|
|
56
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
57
|
+
const published = pkg.name ?? rawName;
|
|
58
|
+
info = {
|
|
59
|
+
name: fromTypesPackage(published),
|
|
60
|
+
version: pkg.version,
|
|
61
|
+
rawName: published,
|
|
62
|
+
root,
|
|
63
|
+
dependencies: Object.keys({ ...pkg.dependencies, ...pkg.peerDependencies }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Keep the name derived from the path.
|
|
68
|
+
}
|
|
69
|
+
infoCache.set(root, info);
|
|
70
|
+
return info;
|
|
71
|
+
}
|
|
72
|
+
/** Version of `name` installed in a node_modules directory visible from `fromDir`. */
|
|
73
|
+
export function installedVersion(name, fromDir) {
|
|
74
|
+
let dir = path.resolve(fromDir);
|
|
75
|
+
for (;;) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "node_modules", name, "package.json"), "utf8")).version;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Keep looking upward.
|
|
81
|
+
}
|
|
82
|
+
const parent = path.dirname(dir);
|
|
83
|
+
if (parent === dir)
|
|
84
|
+
return undefined;
|
|
85
|
+
dir = parent;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Whether `name` is installed in a node_modules directory visible from `fromDir`. */
|
|
89
|
+
export function isInstalled(name, fromDir) {
|
|
90
|
+
let dir = path.resolve(fromDir);
|
|
91
|
+
for (;;) {
|
|
92
|
+
if (fs.existsSync(path.join(dir, "node_modules", name)))
|
|
93
|
+
return true;
|
|
94
|
+
const parent = path.dirname(dir);
|
|
95
|
+
if (parent === dir)
|
|
96
|
+
return false;
|
|
97
|
+
dir = parent;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Yarn Plug'n'Play has no node_modules, so on-disk presence can't be checked. */
|
|
101
|
+
export function usesPnp(cwd) {
|
|
102
|
+
let dir = path.resolve(cwd);
|
|
103
|
+
for (;;) {
|
|
104
|
+
if (fs.existsSync(path.join(dir, ".pnp.cjs")) || fs.existsSync(path.join(dir, ".pnp.js")))
|
|
105
|
+
return true;
|
|
106
|
+
const parent = path.dirname(dir);
|
|
107
|
+
if (parent === dir)
|
|
108
|
+
return false;
|
|
109
|
+
dir = parent;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Every dependency name declared in the nearest package.json. */
|
|
113
|
+
export function declaredDependencies(cwd) {
|
|
114
|
+
const names = new Set();
|
|
115
|
+
let dir = path.resolve(cwd);
|
|
116
|
+
for (;;) {
|
|
117
|
+
const file = path.join(dir, "package.json");
|
|
118
|
+
if (fs.existsSync(file)) {
|
|
119
|
+
try {
|
|
120
|
+
const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
121
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
122
|
+
for (const name of Object.keys(pkg[field] ?? {}))
|
|
123
|
+
names.add(name);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Ignore unreadable package.json.
|
|
128
|
+
}
|
|
129
|
+
return names;
|
|
130
|
+
}
|
|
131
|
+
const parent = path.dirname(dir);
|
|
132
|
+
if (parent === dir)
|
|
133
|
+
return names;
|
|
134
|
+
dir = parent;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Names of every installed `@types/*` package visible from cwd, as `types` entries. */
|
|
138
|
+
export function installedTypesPackages(cwd) {
|
|
139
|
+
const names = new Set();
|
|
140
|
+
let dir = path.resolve(cwd);
|
|
141
|
+
for (;;) {
|
|
142
|
+
const typesDir = path.join(dir, "node_modules", "@types");
|
|
143
|
+
try {
|
|
144
|
+
for (const entry of fs.readdirSync(typesDir)) {
|
|
145
|
+
if (!entry.startsWith("."))
|
|
146
|
+
names.add(entry);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// No @types here.
|
|
151
|
+
}
|
|
152
|
+
const parent = path.dirname(dir);
|
|
153
|
+
if (parent === dir)
|
|
154
|
+
break;
|
|
155
|
+
dir = parent;
|
|
156
|
+
}
|
|
157
|
+
return [...names];
|
|
158
|
+
}
|
|
159
|
+
/** Returns true when the npm registry has no package with this name. */
|
|
160
|
+
export async function missingFromRegistry(name) {
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}`, {
|
|
163
|
+
method: "HEAD",
|
|
164
|
+
signal: AbortSignal.timeout(8000),
|
|
165
|
+
});
|
|
166
|
+
if (res.status === 404)
|
|
167
|
+
return true;
|
|
168
|
+
if (res.ok)
|
|
169
|
+
return false;
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
package/dist/report.d.ts
ADDED
package/dist/report.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
3
|
+
const red = paint("31");
|
|
4
|
+
const yellow = paint("33");
|
|
5
|
+
const green = paint("32");
|
|
6
|
+
const dim = paint("2");
|
|
7
|
+
const bold = paint("1");
|
|
8
|
+
const underline = paint("4");
|
|
9
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
10
|
+
export function formatPretty(result) {
|
|
11
|
+
const out = [];
|
|
12
|
+
let currentFile = "";
|
|
13
|
+
for (const issue of result.issues) {
|
|
14
|
+
if (issue.file !== currentFile) {
|
|
15
|
+
if (currentFile)
|
|
16
|
+
out.push("");
|
|
17
|
+
out.push(underline(issue.file));
|
|
18
|
+
currentFile = issue.file;
|
|
19
|
+
}
|
|
20
|
+
const pos = dim(`${issue.line}:${issue.column}`.padEnd(8));
|
|
21
|
+
const sev = issue.severity === "error" ? red("error ") : yellow("warning");
|
|
22
|
+
out.push(` ${pos} ${sev} ${issue.message} ${dim(issue.kind)}`);
|
|
23
|
+
if (issue.sourceLine)
|
|
24
|
+
out.push(` ${" ".repeat(8)} ${dim(issue.sourceLine)}`);
|
|
25
|
+
}
|
|
26
|
+
const errors = result.issues.filter((i) => i.severity === "error").length;
|
|
27
|
+
const warnings = result.issues.length - errors;
|
|
28
|
+
if (result.issues.length)
|
|
29
|
+
out.push("");
|
|
30
|
+
const scope = dim(`checked ${plural(result.filesChecked, "file")} against ${plural(result.packagesChecked.length, "installed package")}`);
|
|
31
|
+
if (result.issues.length === 0) {
|
|
32
|
+
out.push(`${green("✔")} ${bold("No hallucinated APIs found")} ${scope}`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const color = errors ? red : yellow;
|
|
36
|
+
out.push(`${color("✖")} ${bold(plural(result.issues.length, "problem"))} (${plural(errors, "error")}, ${plural(warnings, "warning")}) ${scope}`);
|
|
37
|
+
}
|
|
38
|
+
if (result.unverifiable.length) {
|
|
39
|
+
const names = result.unverifiable.map((u) => (u.reason === "no-node-types" ? "node built-ins (install @types/node)" : u.package));
|
|
40
|
+
out.push(yellow(`⚠ Not verified, no type definitions: ${names.join(", ")}`));
|
|
41
|
+
}
|
|
42
|
+
return out.join("\n");
|
|
43
|
+
}
|
|
44
|
+
export function formatJson(result) {
|
|
45
|
+
return JSON.stringify(result, null, 2);
|
|
46
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type Severity = "error" | "warning";
|
|
2
|
+
export type IssueKind =
|
|
3
|
+
/** `import { x } from "pkg"` where the installed version doesn't export `x`. */
|
|
4
|
+
"missing-export"
|
|
5
|
+
/** `thing.method()` where the package's type has no such member. */
|
|
6
|
+
| "missing-member"
|
|
7
|
+
/** `fn({ option: 1 })` where the package's options type has no such key. */
|
|
8
|
+
| "unknown-option"
|
|
9
|
+
/** A bare import of a package that isn't installed. */
|
|
10
|
+
| "missing-package"
|
|
11
|
+
/** The API exists but the installed version marks it `@deprecated`. */
|
|
12
|
+
| "deprecated";
|
|
13
|
+
export interface Issue {
|
|
14
|
+
kind: IssueKind;
|
|
15
|
+
severity: Severity;
|
|
16
|
+
/** Path relative to cwd, always with forward slashes. */
|
|
17
|
+
file: string;
|
|
18
|
+
line: number;
|
|
19
|
+
column: number;
|
|
20
|
+
message: string;
|
|
21
|
+
/** The name the code used, e.g. `emial`. */
|
|
22
|
+
symbol?: string;
|
|
23
|
+
/** Package the API belongs to, e.g. `zod` or `node` for built-ins. */
|
|
24
|
+
package?: string;
|
|
25
|
+
version?: string;
|
|
26
|
+
/** Closest real name, when one exists. */
|
|
27
|
+
suggestion?: string;
|
|
28
|
+
/** The trimmed source line, for display. */
|
|
29
|
+
sourceLine: string;
|
|
30
|
+
}
|
|
31
|
+
export interface Unverifiable {
|
|
32
|
+
package: string;
|
|
33
|
+
reason: "no-types" | "no-node-types";
|
|
34
|
+
}
|
|
35
|
+
export interface CheckResult {
|
|
36
|
+
issues: Issue[];
|
|
37
|
+
filesChecked: number;
|
|
38
|
+
/** Installed packages whose types were used to verify imports. */
|
|
39
|
+
packagesChecked: {
|
|
40
|
+
name: string;
|
|
41
|
+
version?: string;
|
|
42
|
+
}[];
|
|
43
|
+
/** Imported packages that could not be verified because they ship no types. */
|
|
44
|
+
unverifiable: Unverifiable[];
|
|
45
|
+
}
|
|
46
|
+
export interface CheckOptions {
|
|
47
|
+
/** Project root. Defaults to `process.cwd()`. */
|
|
48
|
+
cwd?: string;
|
|
49
|
+
/** Files or directories to check, relative to cwd. Defaults to `["."]`. */
|
|
50
|
+
paths?: string[];
|
|
51
|
+
/** Path to a tsconfig/jsconfig. Auto-detected when omitted. */
|
|
52
|
+
project?: string;
|
|
53
|
+
/** Report `@deprecated` APIs as warnings. Defaults to true. */
|
|
54
|
+
deprecated?: boolean;
|
|
55
|
+
/** Look up missing packages on the npm registry. Defaults to false. */
|
|
56
|
+
online?: boolean;
|
|
57
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "realapi-check",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Catch AI-hallucinated imports, methods and options that don't exist in the package versions you actually have installed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"realapi-check": "dist/cli.js",
|
|
8
|
+
"realapi": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"test": "npm run build && vitest run",
|
|
25
|
+
"prepublishOnly": "npm test"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"ai",
|
|
29
|
+
"hallucination",
|
|
30
|
+
"llm",
|
|
31
|
+
"lint",
|
|
32
|
+
"cli",
|
|
33
|
+
"typescript",
|
|
34
|
+
"api",
|
|
35
|
+
"copilot",
|
|
36
|
+
"claude",
|
|
37
|
+
"cursor",
|
|
38
|
+
"static-analysis"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"author": "Naveen Elango",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/navin017/realAPI.git"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/navin017/realAPI#readme",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/navin017/realAPI/issues"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"typescript": "~6.0.3"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^22.0.0",
|
|
58
|
+
"vitest": "^3.2.0"
|
|
59
|
+
}
|
|
60
|
+
}
|