hono-doctor 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/CHANGELOG.md +7 -0
- package/CODE_OF_CONDUCT.md +5 -0
- package/CONTRIBUTING.md +13 -0
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/RELEASING.md +24 -0
- package/SECURITY.md +5 -0
- package/dist/cli-args.d.ts +10 -0
- package/dist/cli-args.js +55 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +48 -0
- package/dist/files.d.ts +3 -0
- package/dist/files.js +59 -0
- package/dist/format.d.ts +4 -0
- package/dist/format.js +57 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +37 -0
- package/dist/package-rules.d.ts +2 -0
- package/dist/package-rules.js +83 -0
- package/dist/source-rules.d.ts +2 -0
- package/dist/source-rules.js +351 -0
- package/dist/types.d.ts +20 -0
- package/dist/types.js +1 -0
- package/docs/release-readiness.md +41 -0
- package/docs/rules.md +34 -0
- package/docs/validation.md +27 -0
- package/package.json +78 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Add read-only analysis for Hono RPC type loss, Promise-chain response inference, middleware ordering, timeout/stream conflicts, and workspace version skew.
|
|
6
|
+
- Add pretty, JSON, and SARIF output.
|
|
7
|
+
- Include rule and validation documentation in the npm package; check packaged entry points and documentation links before release.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
We are committed to a respectful, harassment-free project. Be constructive, assume good intent, and focus criticism on ideas and code. Maintainers may remove abusive, discriminatory, threatening, or disruptive content and restrict participation when necessary.
|
|
4
|
+
|
|
5
|
+
Report conduct concerns privately to the maintainer through the contact options on their GitHub profile.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Bug reports and focused rule proposals are welcome. A new rule must identify a documented Hono contract or a reproducible runtime/type failure and include both positive and negative fixtures.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pnpm install
|
|
7
|
+
pnpm format:check
|
|
8
|
+
pnpm typecheck
|
|
9
|
+
pnpm test
|
|
10
|
+
pnpm build
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Please keep rules deterministic and avoid executing analyzed application code.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wang Xiaoping
|
|
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,80 @@
|
|
|
1
|
+
# hono-doctor
|
|
2
|
+
|
|
3
|
+
Catch Hono RPC typing, middleware ordering, and streaming configuration mistakes before they reach CI or production.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx hono-doctor .
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`hono-doctor` is a read-only static analyzer. It does not rewrite source files, execute application code, contact a service, or collect telemetry.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
Several valid-looking Hono patterns have consequences that are easy to miss:
|
|
14
|
+
|
|
15
|
+
- registering routes after `const app = new Hono()` does not evolve the TypeScript type later exported with `typeof app`;
|
|
16
|
+
- returning a `.then()` chain from a handler can make Hono RPC infer the response as `unknown`;
|
|
17
|
+
- registering global middleware after routes leaves earlier routes outside that middleware;
|
|
18
|
+
- Hono's timeout middleware does not support streaming responses;
|
|
19
|
+
- different installed Hono versions across a monorepo can break RPC compatibility.
|
|
20
|
+
|
|
21
|
+
These constraints are documented by Hono, but they are distributed across the [RPC](https://hono.dev/docs/guides/rpc), [testing](https://hono.dev/docs/guides/testing), [middleware](https://hono.dev/docs/guides/middleware), and [timeout](https://hono.dev/docs/middleware/builtin/timeout) guides. `hono-doctor` turns the deterministic parts into one local check.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
# Human-readable output; exits 1 on warnings or errors
|
|
27
|
+
npx hono-doctor .
|
|
28
|
+
|
|
29
|
+
# Machine-readable output
|
|
30
|
+
npx hono-doctor . --format json
|
|
31
|
+
|
|
32
|
+
# GitHub code scanning and other SARIF consumers
|
|
33
|
+
npx hono-doctor . --format sarif > hono-doctor.sarif
|
|
34
|
+
|
|
35
|
+
# Report warnings but fail CI only on errors
|
|
36
|
+
npx hono-doctor . --fail-on error
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
|
|
41
|
+
| Option | Values | Default |
|
|
42
|
+
| ----------- | -------------------------- | --------- |
|
|
43
|
+
| `--format` | `pretty`, `json`, `sarif` | `pretty` |
|
|
44
|
+
| `--fail-on` | `warning`, `error`, `none` | `warning` |
|
|
45
|
+
|
|
46
|
+
## Rules
|
|
47
|
+
|
|
48
|
+
| Rule | Severity | What it detects |
|
|
49
|
+
| ------- | ------------- | -------------------------------------------------------------------------------------- |
|
|
50
|
+
| `HD001` | error | Standalone route registrations missing from an exported `typeof app` |
|
|
51
|
+
| `HD002` | error | Hono response returned through a `.then()` chain, which can erase RPC output inference |
|
|
52
|
+
| `HD003` | warning | Global middleware registered after one or more routes |
|
|
53
|
+
| `HD004` | error | Hono timeout middleware covering a streaming route in the same file |
|
|
54
|
+
| `HD005` | error/warning | Multiple installed Hono versions, or unverifiable conflicting declared ranges |
|
|
55
|
+
|
|
56
|
+
The analyzer deliberately skips subjective style checks. `HD003` reports only global middleware because path-specific middleware registered later can be intentional. `HD004` requires both the timeout and streaming route to be statically visible in the same file.
|
|
57
|
+
|
|
58
|
+
## Library API
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { scan } from "hono-doctor";
|
|
62
|
+
|
|
63
|
+
const result = await scan(".");
|
|
64
|
+
for (const diagnostic of result.diagnostics) {
|
|
65
|
+
console.log(diagnostic.ruleId, diagnostic.file, diagnostic.line);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Scope
|
|
70
|
+
|
|
71
|
+
- JavaScript and TypeScript source files are supported.
|
|
72
|
+
- Generated output, dependencies, coverage, and common framework build directories are ignored.
|
|
73
|
+
- No application modules are imported or executed.
|
|
74
|
+
- Cross-file route composition is not reconstructed in version 0.1.0.
|
|
75
|
+
|
|
76
|
+
See [rule details](docs/rules.md), [validation plan](docs/validation.md), [contributing](CONTRIBUTING.md), and [security policy](SECURITY.md).
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/RELEASING.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
## First release
|
|
4
|
+
|
|
5
|
+
`pnpm release:check` includes a package-content check for public entry points
|
|
6
|
+
and relative Markdown links. It does not publish to npm.
|
|
7
|
+
|
|
8
|
+
1. Run `pnpm release:check`.
|
|
9
|
+
2. Create the public GitHub repository and push `main`.
|
|
10
|
+
3. Publish `0.1.0` interactively with npm WebAuthn.
|
|
11
|
+
4. Configure npm Trusted Publishing for `.github/workflows/publish.yml` with publish access.
|
|
12
|
+
5. Create and push the `v0.1.0` tag, but do not publish a GitHub release for this already-published version. Doing so would trigger a duplicate npm publication.
|
|
13
|
+
6. Verify the registry tarball and run `npx hono-doctor@0.1.0 --version`.
|
|
14
|
+
|
|
15
|
+
Do not store a long-lived npm publish token.
|
|
16
|
+
|
|
17
|
+
## Subsequent releases
|
|
18
|
+
|
|
19
|
+
1. Update `CHANGELOG.md` and the version in `package.json`.
|
|
20
|
+
2. Run `pnpm release:check`.
|
|
21
|
+
3. Commit and push the release revision.
|
|
22
|
+
4. Create and publish a GitHub release tagged `v<version>`.
|
|
23
|
+
5. The publish workflow releases to npm through Trusted Publishing and provenance.
|
|
24
|
+
6. Verify the registry tarball and run `npx hono-doctor@<version> --version`.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
Please report vulnerabilities privately through GitHub Security Advisories for this repository. Do not include secrets or private source code in a public issue.
|
|
4
|
+
|
|
5
|
+
`hono-doctor` reads local source and package metadata. It must not execute analyzed application modules, make network requests during a scan, or collect telemetry. Reports that violate this boundary are treated as security issues.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type OutputFormat = "pretty" | "json" | "sarif";
|
|
2
|
+
export type FailOn = "warning" | "error" | "none";
|
|
3
|
+
export interface CliOptions {
|
|
4
|
+
target: string;
|
|
5
|
+
format: OutputFormat;
|
|
6
|
+
failOn: FailOn;
|
|
7
|
+
help: boolean;
|
|
8
|
+
version: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseCliArgs(args: string[]): CliOptions;
|
package/dist/cli-args.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
function optionValue(args, index, name) {
|
|
2
|
+
const inlinePrefix = `${name}=`;
|
|
3
|
+
if (args[index].startsWith(inlinePrefix)) {
|
|
4
|
+
const value = args[index].slice(inlinePrefix.length);
|
|
5
|
+
if (!value)
|
|
6
|
+
throw new Error(`Missing value for ${name}`);
|
|
7
|
+
return { value, nextIndex: index };
|
|
8
|
+
}
|
|
9
|
+
const value = args[index + 1];
|
|
10
|
+
if (!value || value.startsWith("-"))
|
|
11
|
+
throw new Error(`Missing value for ${name}`);
|
|
12
|
+
return { value, nextIndex: index + 1 };
|
|
13
|
+
}
|
|
14
|
+
export function parseCliArgs(args) {
|
|
15
|
+
let target = ".";
|
|
16
|
+
let hasTarget = false;
|
|
17
|
+
let format = "pretty";
|
|
18
|
+
let failOn = "warning";
|
|
19
|
+
let help = false;
|
|
20
|
+
let version = false;
|
|
21
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
22
|
+
const argument = args[index];
|
|
23
|
+
if (argument === "-h" || argument === "--help") {
|
|
24
|
+
help = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (argument === "-v" || argument === "--version") {
|
|
28
|
+
version = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (argument === "--format" || argument.startsWith("--format=")) {
|
|
32
|
+
const parsed = optionValue(args, index, "--format");
|
|
33
|
+
if (!["pretty", "json", "sarif"].includes(parsed.value))
|
|
34
|
+
throw new Error(`Unknown format: ${parsed.value}`);
|
|
35
|
+
format = parsed.value;
|
|
36
|
+
index = parsed.nextIndex;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (argument === "--fail-on" || argument.startsWith("--fail-on=")) {
|
|
40
|
+
const parsed = optionValue(args, index, "--fail-on");
|
|
41
|
+
if (!["warning", "error", "none"].includes(parsed.value))
|
|
42
|
+
throw new Error(`Unknown --fail-on value: ${parsed.value}`);
|
|
43
|
+
failOn = parsed.value;
|
|
44
|
+
index = parsed.nextIndex;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (argument.startsWith("-"))
|
|
48
|
+
throw new Error(`Unknown option: ${argument}`);
|
|
49
|
+
if (hasTarget)
|
|
50
|
+
throw new Error(`Unexpected argument: ${argument}`);
|
|
51
|
+
target = argument;
|
|
52
|
+
hasTarget = true;
|
|
53
|
+
}
|
|
54
|
+
return { target, format, failOn, help, version };
|
|
55
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { parseCliArgs } from "./cli-args.js";
|
|
4
|
+
import { formatJson, formatPretty, formatSarif } from "./format.js";
|
|
5
|
+
import { scan } from "./index.js";
|
|
6
|
+
const help = `hono-doctor [directory] [options]
|
|
7
|
+
|
|
8
|
+
Catch Hono RPC typing, middleware ordering, and streaming configuration mistakes.
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
--format <pretty|json|sarif> Output format (default: pretty)
|
|
12
|
+
--fail-on <warning|error|none>
|
|
13
|
+
Exit non-zero at this severity (default: warning)
|
|
14
|
+
-h, --help Show help
|
|
15
|
+
-v, --version Show version`;
|
|
16
|
+
async function packageVersion() {
|
|
17
|
+
const packageFile = new URL("../package.json", import.meta.url);
|
|
18
|
+
const value = JSON.parse(await readFile(packageFile, "utf8"));
|
|
19
|
+
if (typeof value.version !== "string")
|
|
20
|
+
throw new Error("Package version is missing");
|
|
21
|
+
return value.version;
|
|
22
|
+
}
|
|
23
|
+
async function main() {
|
|
24
|
+
const options = parseCliArgs(process.argv.slice(2));
|
|
25
|
+
if (options.help) {
|
|
26
|
+
console.log(help);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (options.version) {
|
|
30
|
+
console.log(await packageVersion());
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const result = await scan(options.target);
|
|
34
|
+
console.log(options.format === "json"
|
|
35
|
+
? formatJson(result)
|
|
36
|
+
: options.format === "sarif"
|
|
37
|
+
? formatSarif(result)
|
|
38
|
+
: formatPretty(result));
|
|
39
|
+
if (options.failOn === "warning" && result.diagnostics.length > 0)
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
if (options.failOn === "error" &&
|
|
42
|
+
result.diagnostics.some((item) => item.severity === "error"))
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
main().catch((error) => {
|
|
46
|
+
console.error(`hono-doctor: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
process.exitCode = 2;
|
|
48
|
+
});
|
package/dist/files.d.ts
ADDED
package/dist/files.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const ignoredDirectories = new Set([
|
|
4
|
+
".git",
|
|
5
|
+
".next",
|
|
6
|
+
".nuxt",
|
|
7
|
+
".output",
|
|
8
|
+
".turbo",
|
|
9
|
+
".vercel",
|
|
10
|
+
"build",
|
|
11
|
+
"coverage",
|
|
12
|
+
"dist",
|
|
13
|
+
"node_modules",
|
|
14
|
+
"out",
|
|
15
|
+
]);
|
|
16
|
+
const sourceExtensions = new Set([
|
|
17
|
+
".js",
|
|
18
|
+
".jsx",
|
|
19
|
+
".mjs",
|
|
20
|
+
".cjs",
|
|
21
|
+
".ts",
|
|
22
|
+
".tsx",
|
|
23
|
+
".mts",
|
|
24
|
+
".cts",
|
|
25
|
+
]);
|
|
26
|
+
async function walk(root, predicate) {
|
|
27
|
+
const found = [];
|
|
28
|
+
const pending = [root];
|
|
29
|
+
while (pending.length > 0) {
|
|
30
|
+
const directory = pending.pop();
|
|
31
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (entry.isSymbolicLink())
|
|
34
|
+
continue;
|
|
35
|
+
const absolute = path.join(directory, entry.name);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
if (!ignoredDirectories.has(entry.name))
|
|
38
|
+
pending.push(absolute);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (entry.isFile() && predicate(absolute))
|
|
42
|
+
found.push(absolute);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return found.sort();
|
|
46
|
+
}
|
|
47
|
+
export function findSourceFiles(root) {
|
|
48
|
+
return walk(root, (file) => {
|
|
49
|
+
if (file.endsWith(".d.ts"))
|
|
50
|
+
return false;
|
|
51
|
+
return sourceExtensions.has(path.extname(file));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function findPackageFiles(root) {
|
|
55
|
+
return walk(root, (file) => path.basename(file) === "package.json");
|
|
56
|
+
}
|
|
57
|
+
export async function readJson(file) {
|
|
58
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
59
|
+
}
|
package/dist/format.d.ts
ADDED
package/dist/format.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export function formatPretty(result) {
|
|
3
|
+
if (result.diagnostics.length === 0) {
|
|
4
|
+
return `hono-doctor: no problems found (${result.filesScanned} source files, ${result.packagesScanned} packages)`;
|
|
5
|
+
}
|
|
6
|
+
const lines = result.diagnostics.flatMap((item) => [
|
|
7
|
+
`${item.file}:${item.line}:${item.column} ${item.severity} ${item.ruleId} ${item.message}`,
|
|
8
|
+
` ${item.help}`,
|
|
9
|
+
]);
|
|
10
|
+
const errors = result.diagnostics.filter((item) => item.severity === "error").length;
|
|
11
|
+
const warnings = result.diagnostics.length - errors;
|
|
12
|
+
lines.push("", `hono-doctor: ${errors} error(s), ${warnings} warning(s) in ${result.filesScanned} source files`);
|
|
13
|
+
return lines.join("\n");
|
|
14
|
+
}
|
|
15
|
+
export function formatJson(result) {
|
|
16
|
+
return JSON.stringify(result, null, 2);
|
|
17
|
+
}
|
|
18
|
+
function sarifLevel(diagnostic) {
|
|
19
|
+
return diagnostic.severity;
|
|
20
|
+
}
|
|
21
|
+
export function formatSarif(result) {
|
|
22
|
+
const rules = [...new Set(result.diagnostics.map((item) => item.ruleId))].map((id) => ({
|
|
23
|
+
id,
|
|
24
|
+
name: id,
|
|
25
|
+
shortDescription: { text: `hono-doctor ${id}` },
|
|
26
|
+
}));
|
|
27
|
+
return JSON.stringify({
|
|
28
|
+
version: "2.1.0",
|
|
29
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
30
|
+
runs: [
|
|
31
|
+
{
|
|
32
|
+
tool: {
|
|
33
|
+
driver: {
|
|
34
|
+
name: "hono-doctor",
|
|
35
|
+
informationUri: "https://github.com/wangxpych/hono-doctor",
|
|
36
|
+
rules,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
results: result.diagnostics.map((item) => ({
|
|
40
|
+
ruleId: item.ruleId,
|
|
41
|
+
level: sarifLevel(item),
|
|
42
|
+
message: { text: `${item.message} ${item.help}` },
|
|
43
|
+
locations: [
|
|
44
|
+
{
|
|
45
|
+
physicalLocation: {
|
|
46
|
+
artifactLocation: {
|
|
47
|
+
uri: item.file.split(path.sep).join("/"),
|
|
48
|
+
},
|
|
49
|
+
region: { startLine: item.line, startColumn: item.column },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
})),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}, null, 2);
|
|
57
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { findPackageFiles, findSourceFiles } from "./files.js";
|
|
3
|
+
import { scanPackages } from "./package-rules.js";
|
|
4
|
+
import { scanSourceFile } from "./source-rules.js";
|
|
5
|
+
async function mapConcurrent(values, concurrency, task) {
|
|
6
|
+
const results = new Array(values.length);
|
|
7
|
+
let nextIndex = 0;
|
|
8
|
+
async function worker() {
|
|
9
|
+
while (nextIndex < values.length) {
|
|
10
|
+
const index = nextIndex;
|
|
11
|
+
nextIndex += 1;
|
|
12
|
+
results[index] = await task(values[index]);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker));
|
|
16
|
+
return results;
|
|
17
|
+
}
|
|
18
|
+
export async function scan(target = ".", options = {}) {
|
|
19
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
20
|
+
const root = path.resolve(cwd, target);
|
|
21
|
+
const [sourceFiles, packageFiles] = await Promise.all([
|
|
22
|
+
findSourceFiles(root),
|
|
23
|
+
findPackageFiles(root),
|
|
24
|
+
]);
|
|
25
|
+
const sourceDiagnostics = await mapConcurrent(sourceFiles, 8, (file) => scanSourceFile(root, file));
|
|
26
|
+
const packageDiagnostics = await scanPackages(root, packageFiles);
|
|
27
|
+
const diagnostics = [...sourceDiagnostics.flat(), ...packageDiagnostics].sort((left, right) => left.file.localeCompare(right.file) ||
|
|
28
|
+
left.line - right.line ||
|
|
29
|
+
left.column - right.column ||
|
|
30
|
+
left.ruleId.localeCompare(right.ruleId));
|
|
31
|
+
return {
|
|
32
|
+
root,
|
|
33
|
+
filesScanned: sourceFiles.length,
|
|
34
|
+
packagesScanned: packageFiles.length,
|
|
35
|
+
diagnostics,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readJson } from "./files.js";
|
|
4
|
+
function declaredHonoRange(value) {
|
|
5
|
+
return (value.dependencies?.hono ??
|
|
6
|
+
value.devDependencies?.hono ??
|
|
7
|
+
value.optionalDependencies?.hono ??
|
|
8
|
+
value.peerDependencies?.hono);
|
|
9
|
+
}
|
|
10
|
+
async function installedHonoVersion(packageFile) {
|
|
11
|
+
let directory = path.dirname(packageFile);
|
|
12
|
+
while (true) {
|
|
13
|
+
const candidate = path.join(directory, "node_modules", "hono", "package.json");
|
|
14
|
+
try {
|
|
15
|
+
const resolved = await realpath(candidate);
|
|
16
|
+
const value = (await readJson(resolved));
|
|
17
|
+
return value.version;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
const parent = path.dirname(directory);
|
|
21
|
+
if (parent === directory)
|
|
22
|
+
return undefined;
|
|
23
|
+
directory = parent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function scanPackages(root, packageFiles) {
|
|
28
|
+
const installations = new Map();
|
|
29
|
+
const declarations = new Map();
|
|
30
|
+
for (const packageFile of packageFiles) {
|
|
31
|
+
let value;
|
|
32
|
+
try {
|
|
33
|
+
value = (await readJson(packageFile));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const range = declaredHonoRange(value);
|
|
39
|
+
if (!range)
|
|
40
|
+
continue;
|
|
41
|
+
const relative = path.relative(root, packageFile) || "package.json";
|
|
42
|
+
declarations.set(range, [...(declarations.get(range) ?? []), relative]);
|
|
43
|
+
const version = await installedHonoVersion(packageFile);
|
|
44
|
+
if (version)
|
|
45
|
+
installations.set(version, [
|
|
46
|
+
...(installations.get(version) ?? []),
|
|
47
|
+
relative,
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
if (installations.size > 1) {
|
|
51
|
+
const details = [...installations.entries()]
|
|
52
|
+
.map(([version, files]) => `${version} in ${files.join(", ")}`)
|
|
53
|
+
.join("; ");
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
ruleId: "HD005",
|
|
57
|
+
severity: "error",
|
|
58
|
+
message: `Multiple installed Hono versions can break RPC type compatibility: ${details}.`,
|
|
59
|
+
file: "package.json",
|
|
60
|
+
line: 1,
|
|
61
|
+
column: 1,
|
|
62
|
+
help: "Align Hono versions across the server and client workspaces, then reinstall the lockfile.",
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
if (installations.size === 0 && declarations.size > 1) {
|
|
67
|
+
const details = [...declarations.entries()]
|
|
68
|
+
.map(([range, files]) => `${range} in ${files.join(", ")}`)
|
|
69
|
+
.join("; ");
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
ruleId: "HD005",
|
|
73
|
+
severity: "warning",
|
|
74
|
+
message: `Hono is declared with different ranges and installed versions could not be verified: ${details}.`,
|
|
75
|
+
file: "package.json",
|
|
76
|
+
line: 1,
|
|
77
|
+
column: 1,
|
|
78
|
+
help: "Install dependencies and rerun hono-doctor, or align the declared Hono ranges.",
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
const routeMethods = new Set([
|
|
5
|
+
"all",
|
|
6
|
+
"delete",
|
|
7
|
+
"get",
|
|
8
|
+
"head",
|
|
9
|
+
"on",
|
|
10
|
+
"options",
|
|
11
|
+
"patch",
|
|
12
|
+
"post",
|
|
13
|
+
"put",
|
|
14
|
+
"route",
|
|
15
|
+
]);
|
|
16
|
+
function location(source, node) {
|
|
17
|
+
const position = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
18
|
+
return { line: position.line + 1, column: position.character + 1 };
|
|
19
|
+
}
|
|
20
|
+
function diagnostic(source, file, node, value) {
|
|
21
|
+
return { ...value, file, ...location(source, node) };
|
|
22
|
+
}
|
|
23
|
+
function importedNames(source, moduleName, importedName) {
|
|
24
|
+
const names = new Set();
|
|
25
|
+
for (const statement of source.statements) {
|
|
26
|
+
if (!ts.isImportDeclaration(statement) ||
|
|
27
|
+
statement.moduleSpecifier.getText(source).slice(1, -1) !== moduleName) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const bindings = statement.importClause?.namedBindings;
|
|
31
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
32
|
+
continue;
|
|
33
|
+
for (const element of bindings.elements) {
|
|
34
|
+
if ((element.propertyName?.text ?? element.name.text) === importedName)
|
|
35
|
+
names.add(element.name.text);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function isRequireCall(node) {
|
|
39
|
+
return Boolean(node &&
|
|
40
|
+
ts.isCallExpression(node) &&
|
|
41
|
+
ts.isIdentifier(node.expression) &&
|
|
42
|
+
node.expression.text === "require" &&
|
|
43
|
+
node.arguments.length === 1 &&
|
|
44
|
+
ts.isStringLiteralLike(node.arguments[0]) &&
|
|
45
|
+
node.arguments[0].text === moduleName);
|
|
46
|
+
}
|
|
47
|
+
for (const statement of source.statements) {
|
|
48
|
+
if (!ts.isVariableStatement(statement))
|
|
49
|
+
continue;
|
|
50
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
51
|
+
if (ts.isObjectBindingPattern(declaration.name) &&
|
|
52
|
+
isRequireCall(declaration.initializer)) {
|
|
53
|
+
for (const element of declaration.name.elements) {
|
|
54
|
+
if (!element.dotDotDotToken &&
|
|
55
|
+
ts.isIdentifier(element.name) &&
|
|
56
|
+
(element.propertyName?.getText(source) ?? element.name.text) ===
|
|
57
|
+
importedName) {
|
|
58
|
+
names.add(element.name.text);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (ts.isIdentifier(declaration.name) &&
|
|
63
|
+
declaration.initializer &&
|
|
64
|
+
ts.isPropertyAccessExpression(declaration.initializer) &&
|
|
65
|
+
declaration.initializer.name.text === importedName &&
|
|
66
|
+
isRequireCall(declaration.initializer.expression)) {
|
|
67
|
+
names.add(declaration.name.text);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return names;
|
|
72
|
+
}
|
|
73
|
+
function rootIdentifier(expression) {
|
|
74
|
+
let current = expression;
|
|
75
|
+
while (true) {
|
|
76
|
+
if (ts.isCallExpression(current) ||
|
|
77
|
+
ts.isPropertyAccessExpression(current)) {
|
|
78
|
+
current = current.expression;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ts.isParenthesizedExpression(current) ||
|
|
82
|
+
ts.isAsExpression(current) ||
|
|
83
|
+
ts.isTypeAssertionExpression(current) ||
|
|
84
|
+
ts.isNonNullExpression(current) ||
|
|
85
|
+
ts.isSatisfiesExpression(current)) {
|
|
86
|
+
current = current.expression;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (ts.isNewExpression(current))
|
|
92
|
+
current = current.expression;
|
|
93
|
+
return ts.isIdentifier(current) ? current : undefined;
|
|
94
|
+
}
|
|
95
|
+
function registrationChain(app, expression, statement, representedByAppType) {
|
|
96
|
+
const calls = [];
|
|
97
|
+
function visit(current) {
|
|
98
|
+
if (ts.isParenthesizedExpression(current) ||
|
|
99
|
+
ts.isAsExpression(current) ||
|
|
100
|
+
ts.isTypeAssertionExpression(current) ||
|
|
101
|
+
ts.isNonNullExpression(current) ||
|
|
102
|
+
ts.isSatisfiesExpression(current)) {
|
|
103
|
+
visit(current.expression);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!ts.isCallExpression(current) ||
|
|
107
|
+
!ts.isPropertyAccessExpression(current.expression))
|
|
108
|
+
return;
|
|
109
|
+
visit(current.expression.expression);
|
|
110
|
+
calls.push({
|
|
111
|
+
app,
|
|
112
|
+
method: current.expression.name.text,
|
|
113
|
+
methodNode: current.expression.name,
|
|
114
|
+
call: current,
|
|
115
|
+
statement,
|
|
116
|
+
representedByAppType,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
visit(expression);
|
|
120
|
+
return calls;
|
|
121
|
+
}
|
|
122
|
+
function containsNamedCall(node, names, method) {
|
|
123
|
+
let matched = false;
|
|
124
|
+
function visit(current) {
|
|
125
|
+
if (matched)
|
|
126
|
+
return;
|
|
127
|
+
if (ts.isCallExpression(current)) {
|
|
128
|
+
const root = rootIdentifier(current.expression);
|
|
129
|
+
if (!method &&
|
|
130
|
+
ts.isIdentifier(current.expression) &&
|
|
131
|
+
names.has(current.expression.text))
|
|
132
|
+
matched = true;
|
|
133
|
+
if (method &&
|
|
134
|
+
ts.isPropertyAccessExpression(current.expression) &&
|
|
135
|
+
current.expression.name.text === method &&
|
|
136
|
+
root &&
|
|
137
|
+
names.has(root.text)) {
|
|
138
|
+
matched = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
ts.forEachChild(current, visit);
|
|
142
|
+
}
|
|
143
|
+
visit(node);
|
|
144
|
+
return matched;
|
|
145
|
+
}
|
|
146
|
+
function isExportedAppType(statement, app) {
|
|
147
|
+
if (!ts.isTypeAliasDeclaration(statement))
|
|
148
|
+
return false;
|
|
149
|
+
if (!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))
|
|
150
|
+
return false;
|
|
151
|
+
const type = statement.type;
|
|
152
|
+
return (ts.isTypeQueryNode(type) &&
|
|
153
|
+
ts.isIdentifier(type.exprName) &&
|
|
154
|
+
type.exprName.text === app);
|
|
155
|
+
}
|
|
156
|
+
function isHonoInitializer(expression, honoNames) {
|
|
157
|
+
if (!expression)
|
|
158
|
+
return false;
|
|
159
|
+
const root = rootIdentifier(expression);
|
|
160
|
+
return Boolean(root && honoNames.has(root.text));
|
|
161
|
+
}
|
|
162
|
+
function hasThenReturningHonoResponse(expression, contextName) {
|
|
163
|
+
let matched = false;
|
|
164
|
+
function containsResponse(current) {
|
|
165
|
+
if (ts.isCallExpression(current) &&
|
|
166
|
+
ts.isPropertyAccessExpression(current.expression) &&
|
|
167
|
+
ts.isIdentifier(current.expression.expression) &&
|
|
168
|
+
current.expression.expression.text === contextName &&
|
|
169
|
+
["body", "html", "json", "text"].includes(current.expression.name.text)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return current.getChildren().some(containsResponse);
|
|
173
|
+
}
|
|
174
|
+
function visit(current) {
|
|
175
|
+
if (matched)
|
|
176
|
+
return;
|
|
177
|
+
if (ts.isCallExpression(current) &&
|
|
178
|
+
ts.isPropertyAccessExpression(current.expression) &&
|
|
179
|
+
current.expression.name.text === "then" &&
|
|
180
|
+
!current.typeArguments?.length &&
|
|
181
|
+
current.arguments.some(containsResponse)) {
|
|
182
|
+
matched = true;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
ts.forEachChild(current, visit);
|
|
186
|
+
}
|
|
187
|
+
visit(expression);
|
|
188
|
+
return matched;
|
|
189
|
+
}
|
|
190
|
+
function returnedExpressions(handler) {
|
|
191
|
+
if (!ts.isBlock(handler.body))
|
|
192
|
+
return [handler.body];
|
|
193
|
+
const expressions = [];
|
|
194
|
+
function visit(node) {
|
|
195
|
+
if (ts.isFunctionLike(node) && node !== handler)
|
|
196
|
+
return;
|
|
197
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
198
|
+
expressions.push(node.expression);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
ts.forEachChild(node, visit);
|
|
202
|
+
}
|
|
203
|
+
visit(handler.body);
|
|
204
|
+
return expressions;
|
|
205
|
+
}
|
|
206
|
+
function literalPath(call) {
|
|
207
|
+
const first = call.arguments[0];
|
|
208
|
+
return first && ts.isStringLiteralLike(first) ? first.text : undefined;
|
|
209
|
+
}
|
|
210
|
+
function pathsOverlap(middlewarePath, routePath) {
|
|
211
|
+
if (!middlewarePath || middlewarePath === "*" || middlewarePath === "/*")
|
|
212
|
+
return true;
|
|
213
|
+
if (!routePath)
|
|
214
|
+
return false;
|
|
215
|
+
const prefix = middlewarePath.replace(/\*+$/, "").replace(/\/$/, "");
|
|
216
|
+
return routePath === prefix || routePath.startsWith(`${prefix}/`);
|
|
217
|
+
}
|
|
218
|
+
function usesTimeout(call, timeoutNames) {
|
|
219
|
+
return call.arguments.some((argument) => containsNamedCall(argument, timeoutNames));
|
|
220
|
+
}
|
|
221
|
+
export async function scanSourceFile(root, absoluteFile) {
|
|
222
|
+
const text = await readFile(absoluteFile, "utf8");
|
|
223
|
+
const scriptKind = absoluteFile.endsWith("x")
|
|
224
|
+
? ts.ScriptKind.TSX
|
|
225
|
+
: ts.ScriptKind.TS;
|
|
226
|
+
const source = ts.createSourceFile(absoluteFile, text, ts.ScriptTarget.Latest, true, scriptKind);
|
|
227
|
+
const file = path.relative(root, absoluteFile) || path.basename(absoluteFile);
|
|
228
|
+
const diagnostics = [];
|
|
229
|
+
const honoNames = importedNames(source, "hono", "Hono");
|
|
230
|
+
if (honoNames.size === 0)
|
|
231
|
+
return diagnostics;
|
|
232
|
+
const timeoutNames = importedNames(source, "hono/timeout", "timeout");
|
|
233
|
+
const streamNames = new Set([
|
|
234
|
+
...importedNames(source, "hono/streaming", "stream"),
|
|
235
|
+
...importedNames(source, "hono/streaming", "streamSSE"),
|
|
236
|
+
]);
|
|
237
|
+
const apps = new Map();
|
|
238
|
+
const calls = [];
|
|
239
|
+
for (const statement of source.statements) {
|
|
240
|
+
if (ts.isVariableStatement(statement)) {
|
|
241
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
242
|
+
if (ts.isIdentifier(declaration.name) &&
|
|
243
|
+
isHonoInitializer(declaration.initializer, honoNames)) {
|
|
244
|
+
apps.set(declaration.name.text, declaration);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const statement of source.statements) {
|
|
250
|
+
if (ts.isVariableStatement(statement)) {
|
|
251
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
252
|
+
if (ts.isIdentifier(declaration.name) &&
|
|
253
|
+
declaration.initializer &&
|
|
254
|
+
apps.has(declaration.name.text)) {
|
|
255
|
+
calls.push(...registrationChain(declaration.name.text, declaration.initializer, statement, !(declaration.type &&
|
|
256
|
+
ts.isTypeReferenceNode(declaration.type) &&
|
|
257
|
+
ts.isIdentifier(declaration.type.typeName) &&
|
|
258
|
+
honoNames.has(declaration.type.typeName.text))));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (ts.isExpressionStatement(statement)) {
|
|
263
|
+
const root = rootIdentifier(statement.expression);
|
|
264
|
+
if (root && apps.has(root.text)) {
|
|
265
|
+
calls.push(...registrationChain(root.text, statement.expression, statement, false));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const [app, declaration] of apps) {
|
|
270
|
+
const typeExport = source.statements.find((statement) => isExportedAppType(statement, app));
|
|
271
|
+
if (typeExport) {
|
|
272
|
+
const untracked = calls.filter((call) => call.app === app &&
|
|
273
|
+
routeMethods.has(call.method) &&
|
|
274
|
+
!call.representedByAppType);
|
|
275
|
+
if (untracked.length > 0) {
|
|
276
|
+
diagnostics.push(diagnostic(source, file, untracked[0].methodNode, {
|
|
277
|
+
ruleId: "HD001",
|
|
278
|
+
severity: "error",
|
|
279
|
+
message: `${untracked.length} route registration${untracked.length === 1 ? " is" : "s are"} not represented by the exported typeof ${app}.`,
|
|
280
|
+
help: `Chain registrations into the variable whose type is exported, or export typeof the final chained value.`,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const appCalls = calls.filter((call) => call.app === app);
|
|
285
|
+
let sawRoute = false;
|
|
286
|
+
for (const call of appCalls) {
|
|
287
|
+
if (routeMethods.has(call.method))
|
|
288
|
+
sawRoute = true;
|
|
289
|
+
if (call.method === "use" && sawRoute) {
|
|
290
|
+
const first = call.call.arguments[0];
|
|
291
|
+
const global = !first ||
|
|
292
|
+
!ts.isStringLiteralLike(first) ||
|
|
293
|
+
first.text === "*" ||
|
|
294
|
+
first.text === "/*";
|
|
295
|
+
if (global) {
|
|
296
|
+
diagnostics.push(diagnostic(source, file, call.methodNode, {
|
|
297
|
+
ruleId: "HD003",
|
|
298
|
+
severity: "warning",
|
|
299
|
+
message: "A global middleware is registered after a route and will not wrap earlier routes.",
|
|
300
|
+
help: "Move global app.use(...) registrations before the first route, or use an explicit path when partial coverage is intentional.",
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const timeouts = appCalls.filter((call) => call.method === "use" && usesTimeout(call.call, timeoutNames));
|
|
306
|
+
for (const route of appCalls.filter((call) => routeMethods.has(call.method))) {
|
|
307
|
+
if (streamNames.size === 0 ||
|
|
308
|
+
!route.call.arguments.some((argument) => containsNamedCall(argument, streamNames)))
|
|
309
|
+
continue;
|
|
310
|
+
const conflicting = timeouts.find((middleware) => middleware.call.expression.name.getStart(source) <
|
|
311
|
+
route.call.expression.name.getStart(source) &&
|
|
312
|
+
pathsOverlap(literalPath(middleware.call), literalPath(route.call)));
|
|
313
|
+
if (conflicting) {
|
|
314
|
+
diagnostics.push(diagnostic(source, file, route.methodNode, {
|
|
315
|
+
ruleId: "HD004",
|
|
316
|
+
severity: "error",
|
|
317
|
+
message: "This streaming route is covered by Hono's timeout middleware, which does not support streams.",
|
|
318
|
+
help: "Exclude the streaming path from timeout middleware and close the stream explicitly when needed.",
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function visit(node) {
|
|
324
|
+
if (ts.isCallExpression(node) &&
|
|
325
|
+
ts.isPropertyAccessExpression(node.expression)) {
|
|
326
|
+
const method = node.expression.name.text;
|
|
327
|
+
const root = rootIdentifier(node.expression);
|
|
328
|
+
if (routeMethods.has(method) &&
|
|
329
|
+
root &&
|
|
330
|
+
(apps.has(root.text) || honoNames.has(root.text))) {
|
|
331
|
+
for (const argument of node.arguments) {
|
|
332
|
+
if ((ts.isArrowFunction(argument) ||
|
|
333
|
+
ts.isFunctionExpression(argument)) &&
|
|
334
|
+
argument.parameters[0] &&
|
|
335
|
+
ts.isIdentifier(argument.parameters[0].name) &&
|
|
336
|
+
returnedExpressions(argument).some((expression) => hasThenReturningHonoResponse(expression, argument.parameters[0].name.text))) {
|
|
337
|
+
diagnostics.push(diagnostic(source, file, argument, {
|
|
338
|
+
ruleId: "HD002",
|
|
339
|
+
severity: "error",
|
|
340
|
+
message: "A Hono handler returns a .then() chain containing a response, so RPC output can be inferred as unknown.",
|
|
341
|
+
help: "Use an async handler with await, or annotate the Promise callback with TypedResponse.",
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
ts.forEachChild(node, visit);
|
|
348
|
+
}
|
|
349
|
+
visit(source);
|
|
350
|
+
return diagnostics;
|
|
351
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type Severity = "warning" | "error";
|
|
2
|
+
export type RuleId = "HD001" | "HD002" | "HD003" | "HD004" | "HD005";
|
|
3
|
+
export interface Diagnostic {
|
|
4
|
+
ruleId: RuleId;
|
|
5
|
+
severity: Severity;
|
|
6
|
+
message: string;
|
|
7
|
+
file: string;
|
|
8
|
+
line: number;
|
|
9
|
+
column: number;
|
|
10
|
+
help: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ScanOptions {
|
|
13
|
+
cwd?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ScanResult {
|
|
16
|
+
root: string;
|
|
17
|
+
filesScanned: number;
|
|
18
|
+
packagesScanned: number;
|
|
19
|
+
diagnostics: Diagnostic[];
|
|
20
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Release readiness — 2026-09-05
|
|
2
|
+
|
|
3
|
+
Version 0.1.0 is locally prepared for its first release. Publication is explicitly
|
|
4
|
+
deferred by the owner. This is not a claim that remote CI, npm publication or
|
|
5
|
+
Trusted Publishing has succeeded.
|
|
6
|
+
|
|
7
|
+
## Verified locally
|
|
8
|
+
|
|
9
|
+
- `pnpm release:check`: formatting, type checking, 36 tests, coverage, build,
|
|
10
|
+
package-content checks and npm publish dry-run passed.
|
|
11
|
+
- Statement coverage: 93.87%; branch coverage: 88.02%.
|
|
12
|
+
- Hono 4.13.5 runtime middleware-order tests and compile-time RPC contracts are
|
|
13
|
+
included in the checks. No deployed application is needed by this static tool.
|
|
14
|
+
- The npm archive installed in a fresh temporary consumer with only the package
|
|
15
|
+
and its runtime dependency, without lifecycle scripts or repository links.
|
|
16
|
+
- Installed CLI/API smoke passed on Node 20.20.2, 22.14.0 and 24.20.0: version,
|
|
17
|
+
positive HD001 detection, a clean chained-route fixture, JSON, SARIF, public
|
|
18
|
+
`scan` import, exit 1 for findings, exit 0 with `--fail-on none`, and exit 2
|
|
19
|
+
for a missing directory. The installed executable link also returned 0.1.0.
|
|
20
|
+
- A separate intentionally incomplete package was rejected by the package
|
|
21
|
+
checker for a README link to an unpackaged rules file (exit 1).
|
|
22
|
+
- Installation audit reported zero vulnerabilities at the time of the check.
|
|
23
|
+
|
|
24
|
+
## Release actions still deferred
|
|
25
|
+
|
|
26
|
+
The local repository has no configured remote. Read-only checks of the expected
|
|
27
|
+
GitHub repository and npm package returned 404 on this date; this is not a
|
|
28
|
+
reservation of either name. Recheck availability when publishing.
|
|
29
|
+
|
|
30
|
+
Follow [the release procedure](../RELEASING.md) when the owner is ready. Creating
|
|
31
|
+
the remote, pushing changes, first interactive publication, configuring npm
|
|
32
|
+
Trusted Publishing, and verifying the public registry artifact remain undone.
|
|
33
|
+
The first interactive publication may require the owner's WebAuthn approval.
|
|
34
|
+
|
|
35
|
+
## Adoption is not yet validated
|
|
36
|
+
|
|
37
|
+
Passing these checks establishes local technical readiness, not usefulness in
|
|
38
|
+
every Hono codebase. Cross-file composition remains outside the documented
|
|
39
|
+
scope. External maintainer feedback and retained real-project integrations in
|
|
40
|
+
[the validation plan](validation.md) are post-release evidence gates and have
|
|
41
|
+
not been satisfied by local tests or download counts.
|
package/docs/rules.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Rule details
|
|
2
|
+
|
|
3
|
+
## HD001 — exported Hono type misses registrations
|
|
4
|
+
|
|
5
|
+
Hono's [RPC guide](https://hono.dev/docs/guides/rpc) and [testing guide](https://hono.dev/docs/guides/testing) explain that client and test helpers infer routes from the type returned by chained calls. TypeScript does not mutate the declared type of a variable when a later method call returns a more specific Hono type.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// Reported
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
app.get("/users", handler);
|
|
11
|
+
export type AppType = typeof app;
|
|
12
|
+
|
|
13
|
+
// Preferred
|
|
14
|
+
const app = new Hono().get("/users", handler);
|
|
15
|
+
export type AppType = typeof app;
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## HD002 — Promise chain erases RPC response inference
|
|
19
|
+
|
|
20
|
+
Hono's [RPC guide](https://hono.dev/docs/guides/rpc) documents a TypeScript inference limitation for handlers that directly return `.then()` chains. Prefer an `async` handler with `await`, or explicitly annotate the callback with `TypedResponse`.
|
|
21
|
+
|
|
22
|
+
## HD003 — global middleware registered after routes
|
|
23
|
+
|
|
24
|
+
Hono's [middleware guide](https://hono.dev/docs/guides/middleware) specifies that middleware executes according to registration order. A global `app.use(...)`, `app.use("*", ...)`, or `app.use("/*", ...)` registered after a route does not wrap that earlier route.
|
|
25
|
+
|
|
26
|
+
Path-specific late middleware is not reported because limiting middleware to later routes can be intentional.
|
|
27
|
+
|
|
28
|
+
## HD004 — timeout middleware covers a stream
|
|
29
|
+
|
|
30
|
+
Hono's [timeout middleware documentation](https://hono.dev/docs/middleware/builtin/timeout) says the middleware does not support streaming responses. The rule reports a conflict only when it can see the timeout registration, streaming route, compatible paths, and ordering in the same file.
|
|
31
|
+
|
|
32
|
+
## HD005 — Hono version skew
|
|
33
|
+
|
|
34
|
+
Hono's [RPC troubleshooting guidance](https://hono.dev/docs/guides/rpc#troubleshooting) recommends matching backend and frontend versions when sharing RPC types. The rule first checks installed `node_modules/hono/package.json` files. If dependencies are not installed, different declared ranges are reported as a warning rather than a confirmed error.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Validation plan
|
|
2
|
+
|
|
3
|
+
`hono-doctor` is for TypeScript teams that use Hono RPC, `hc`, or `testClient` and want deterministic feedback before CI or production. It is not a general Hono linter, formatter, runtime monitor, or security scanner.
|
|
4
|
+
|
|
5
|
+
## Initial hypothesis
|
|
6
|
+
|
|
7
|
+
The package is useful if it finds documented Hono mistakes in real repositories with fewer false positives than a broad lint rule, and maintainers choose to keep it in their workflow.
|
|
8
|
+
|
|
9
|
+
The first implementation is deliberately limited to five statically verifiable rules. Cross-file route reconstruction, automatic rewriting, editor extensions, and hosted reporting are out of scope.
|
|
10
|
+
|
|
11
|
+
## Time box
|
|
12
|
+
|
|
13
|
+
- Initial implementation: 2–3 focused days.
|
|
14
|
+
- Hardening and real-repository validation: up to 10 additional days.
|
|
15
|
+
- After release, keep maintenance narrow until there is adoption evidence.
|
|
16
|
+
|
|
17
|
+
## Evidence gates
|
|
18
|
+
|
|
19
|
+
Within 30 days of the first release, seek all of the following:
|
|
20
|
+
|
|
21
|
+
- runs against at least three independently maintained Hono repositories;
|
|
22
|
+
- at least one confirmed finding or false-positive report from an external maintainer;
|
|
23
|
+
- at least one external repository that keeps `hono-doctor` in a script, CI workflow, or dependency.
|
|
24
|
+
|
|
25
|
+
npm downloads alone do not satisfy these gates: installs can come from CI, mirrors, automated scanners, or repeated downloads by the same user.
|
|
26
|
+
|
|
27
|
+
If no external repository keeps the tool after targeted validation, freeze the package at a stable low-maintenance version instead of adding speculative rules. Expand scope only when a reproducible issue or documented Hono contract justifies it.
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hono-doctor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Catch Hono RPC typing, middleware ordering, and streaming configuration mistakes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"bin": {
|
|
8
|
+
"hono-doctor": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"docs",
|
|
21
|
+
"README.md",
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"CODE_OF_CONDUCT.md",
|
|
24
|
+
"CONTRIBUTING.md",
|
|
25
|
+
"RELEASING.md",
|
|
26
|
+
"SECURITY.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.build.json",
|
|
31
|
+
"format": "prettier --write .",
|
|
32
|
+
"format:check": "prettier --check .",
|
|
33
|
+
"prepack": "pnpm build",
|
|
34
|
+
"release:check": "pnpm format:check && pnpm typecheck && pnpm test:coverage && pnpm build && pnpm test:package && npm publish --dry-run --cache .cache/npm",
|
|
35
|
+
"test:package": "node scripts/check-package.mjs",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:coverage": "vitest run --coverage",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"hono",
|
|
43
|
+
"hono-rpc",
|
|
44
|
+
"static-analysis",
|
|
45
|
+
"doctor",
|
|
46
|
+
"middleware",
|
|
47
|
+
"typescript"
|
|
48
|
+
],
|
|
49
|
+
"author": "wangxpych",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/wangxpych/hono-doctor.git"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/wangxpych/hono-doctor#readme",
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/wangxpych/hono-doctor/issues"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public",
|
|
61
|
+
"provenance": true
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=20"
|
|
65
|
+
},
|
|
66
|
+
"packageManager": "pnpm@10.34.5",
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"typescript": ">=5.6 <7"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@types/node": "^26.4.0",
|
|
72
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
73
|
+
"hono": "4.13.5",
|
|
74
|
+
"prettier": "^3.9.6",
|
|
75
|
+
"typescript": "6.0.3",
|
|
76
|
+
"vitest": "^4.1.11"
|
|
77
|
+
}
|
|
78
|
+
}
|