vite-plugin-nudo 0.2.0 → 0.3.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 +24 -0
- package/README.md +38 -0
- package/dist/index.js +89 -21
- package/package.json +14 -2
- package/src/__tests__/plugin.test.ts +127 -13
- package/src/index.ts +116 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# vite-plugin-nudo
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 5786fa5: Ship TypeScript-aware defaults and Abs check diagnostics in the Vite plugin.
|
|
8
|
+
|
|
9
|
+
- Default include covers `.js` / `.mjs` / `.ts` / `.mts`; exclude also skips `*.d.ts`.
|
|
10
|
+
- Glob matching is a real anchored RegExp (not `endsWith` heuristics).
|
|
11
|
+
- Runs `analyzeFileAsync` plus `checkSource` so Abs constraint issues surface as Vite warnings/errors.
|
|
12
|
+
- Exposes `clearAnalysisSessionCaches` for long-lived Vite processes.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- Updated dependencies [5786fa5]
|
|
17
|
+
- Updated dependencies [5786fa5]
|
|
18
|
+
- @nudojs/core@0.3.0
|
|
19
|
+
- @nudojs/service@0.3.0
|
|
20
|
+
|
|
21
|
+
## 0.2.1
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- @nudojs/service@0.2.1
|
|
26
|
+
|
|
3
27
|
## 0.2.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# vite-plugin-nudo
|
|
2
|
+
|
|
3
|
+
Vite plugin for build-time JavaScript type inference with [Nudo](https://github.com/nudojs/nudo).
|
|
4
|
+
|
|
5
|
+
## What is Nudo?
|
|
6
|
+
|
|
7
|
+
Nudo is a type inference engine for JavaScript. Instead of a separate type system, it runs your code with symbolic type values via abstract interpretation — no TypeScript, no build step.
|
|
8
|
+
|
|
9
|
+
## This package
|
|
10
|
+
|
|
11
|
+
`vite-plugin-nudo` runs Nudo analysis during your Vite build, reporting type diagnostics as build warnings or errors.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -D vite-plugin-nudo
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
// vite.config.js
|
|
23
|
+
import nudo from 'vite-plugin-nudo'
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
plugins: [
|
|
27
|
+
nudo({
|
|
28
|
+
include: '**/*.js', // default
|
|
29
|
+
exclude: '**/node_modules/**', // default
|
|
30
|
+
failOnError: false, // default
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
[MIT](https://github.com/nudojs/nudo/blob/main/LICENSE)
|
package/dist/index.js
CHANGED
|
@@ -1,37 +1,105 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import {
|
|
3
|
+
analyzeFileAsync,
|
|
4
|
+
defaultLoadModule as loadModule,
|
|
5
|
+
clearAnalysisSessionCaches
|
|
6
|
+
} from "@nudojs/service";
|
|
7
|
+
import { checkSource, pTrue } from "@nudojs/core";
|
|
8
|
+
var NUDO_DIRECTIVE_RE = /@nudo:(case|mock|pure|skip|sample|refine|import|env|mock-module|as|replace)\b/;
|
|
9
|
+
function checkIssuesToDiagnostics(id, code) {
|
|
10
|
+
try {
|
|
11
|
+
const report = checkSource(id, code, pTrue, { loadModule, fromFile: id });
|
|
12
|
+
return report.issues.filter((i) => i.severity === "error" || i.severity === "warning").map((i) => {
|
|
13
|
+
const line = i.line ?? 1;
|
|
14
|
+
const column = i.column ?? 0;
|
|
15
|
+
const parts = [i.message];
|
|
16
|
+
if (i.actual) parts.push(`actual: ${i.actual}`);
|
|
17
|
+
if (i.expected) parts.push(`expected: ${i.expected}`);
|
|
18
|
+
if (i.suggestion) parts.push(i.suggestion);
|
|
19
|
+
if (i.code) parts.push(`(${i.code})`);
|
|
20
|
+
return {
|
|
21
|
+
severity: i.severity === "error" ? "error" : "warning",
|
|
22
|
+
message: parts.join(" \xB7 "),
|
|
23
|
+
code: i.code,
|
|
24
|
+
range: {
|
|
25
|
+
start: { line, column },
|
|
26
|
+
end: { line, column: column + 1 }
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
var DEFAULT_INCLUDE = ["**/*.js", "**/*.mjs", "**/*.ts", "**/*.mts"];
|
|
35
|
+
var DEFAULT_EXCLUDE = ["**/node_modules/**", "**/*.d.ts"];
|
|
36
|
+
var REGEX_SPECIALS = /[\\^$.|?*+(){}\[\]]/;
|
|
37
|
+
function escapeRegExpChar(ch) {
|
|
38
|
+
return REGEX_SPECIALS.test(ch) ? `\\${ch}` : ch;
|
|
9
39
|
}
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
40
|
+
function segmentToRegExpSource(segment) {
|
|
41
|
+
let source = "";
|
|
42
|
+
for (const ch of segment) {
|
|
43
|
+
if (ch === "*") source += "[^/]*";
|
|
44
|
+
else if (ch === "?") source += "[^/]";
|
|
45
|
+
else source += escapeRegExpChar(ch);
|
|
15
46
|
}
|
|
16
|
-
return
|
|
47
|
+
return source;
|
|
48
|
+
}
|
|
49
|
+
function patternToRegExp(pattern) {
|
|
50
|
+
const segments = pattern.split("/");
|
|
51
|
+
let source = "";
|
|
52
|
+
for (let i = 0; i < segments.length; i++) {
|
|
53
|
+
const segment = segments[i];
|
|
54
|
+
const isLast = i === segments.length - 1;
|
|
55
|
+
if (segment === "**") {
|
|
56
|
+
if (isLast) {
|
|
57
|
+
source = source.endsWith("/") ? `${source.slice(0, -1)}(?:/.*)?` : `${source}.*`;
|
|
58
|
+
} else {
|
|
59
|
+
source += "(?:.*/)?";
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
source += segmentToRegExpSource(segment);
|
|
63
|
+
if (!isLast) source += "/";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return new RegExp(`^${source}$`);
|
|
67
|
+
}
|
|
68
|
+
function compilePattern(pattern) {
|
|
69
|
+
if (!/[*?]/.test(pattern)) return (id) => id.includes(pattern);
|
|
70
|
+
const regExp = patternToRegExp(pattern);
|
|
71
|
+
return (id) => regExp.test(id);
|
|
72
|
+
}
|
|
73
|
+
function compileAnyMatcher(patterns) {
|
|
74
|
+
const matchers = patterns.map(compilePattern);
|
|
75
|
+
return (id) => matchers.some((match) => match(id));
|
|
17
76
|
}
|
|
18
77
|
function nudoPlugin(options = {}) {
|
|
19
|
-
const
|
|
20
|
-
const
|
|
78
|
+
const includeMatch = compileAnyMatcher(options.include ?? DEFAULT_INCLUDE);
|
|
79
|
+
const excludeMatch = compileAnyMatcher(options.exclude ?? DEFAULT_EXCLUDE);
|
|
21
80
|
const failOnError = options.failOnError ?? false;
|
|
22
81
|
const analysisCache = /* @__PURE__ */ new Map();
|
|
23
82
|
return {
|
|
24
83
|
name: "vite-plugin-nudo",
|
|
25
84
|
buildStart() {
|
|
26
85
|
analysisCache.clear();
|
|
86
|
+
clearAnalysisSessionCaches();
|
|
87
|
+
},
|
|
88
|
+
/** 任一文件变更后,未改 source 的 importer 再 transform 时可能命中陈旧会话缓存 */
|
|
89
|
+
watchChange() {
|
|
90
|
+
analysisCache.clear();
|
|
91
|
+
clearAnalysisSessionCaches();
|
|
27
92
|
},
|
|
28
|
-
transform(code, id) {
|
|
29
|
-
if (
|
|
30
|
-
if (
|
|
93
|
+
async transform(code, id) {
|
|
94
|
+
if (excludeMatch(id)) return null;
|
|
95
|
+
if (!includeMatch(id)) return null;
|
|
96
|
+
if (!NUDO_DIRECTIVE_RE.test(code)) return null;
|
|
31
97
|
try {
|
|
32
|
-
const result =
|
|
33
|
-
|
|
34
|
-
|
|
98
|
+
const result = await analyzeFileAsync(id, code);
|
|
99
|
+
const checkDiags = checkIssuesToDiagnostics(id, code);
|
|
100
|
+
const merged = { ...result, diagnostics: [...result.diagnostics, ...checkDiags] };
|
|
101
|
+
analysisCache.set(id, merged);
|
|
102
|
+
for (const diag of merged.diagnostics) {
|
|
35
103
|
const loc = `${id}:${diag.range.start.line}:${diag.range.start.column}`;
|
|
36
104
|
const msg = `[nudo] ${loc} ${diag.severity}: ${diag.message}`;
|
|
37
105
|
if (diag.severity === "error") {
|
package/package.json
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-nudo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Vite plugin for build-time JavaScript type inference with Nudo",
|
|
4
5
|
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"nudo",
|
|
9
|
+
"type-inference",
|
|
10
|
+
"javascript",
|
|
11
|
+
"vite",
|
|
12
|
+
"vite-plugin"
|
|
13
|
+
],
|
|
5
14
|
"main": "src/index.ts",
|
|
6
15
|
"repository": {
|
|
7
16
|
"type": "git",
|
|
8
17
|
"url": "git+https://github.com/nudojs/nudo.git",
|
|
9
18
|
"directory": "packages/vite-plugin"
|
|
10
19
|
},
|
|
20
|
+
"homepage": "https://github.com/nudojs/nudo/tree/main/packages/vite-plugin",
|
|
21
|
+
"bugs": "https://github.com/nudojs/nudo/issues",
|
|
11
22
|
"publishConfig": {
|
|
12
23
|
"access": "public",
|
|
13
24
|
"provenance": true,
|
|
14
25
|
"registry": "https://registry.npmjs.org"
|
|
15
26
|
},
|
|
16
27
|
"dependencies": {
|
|
17
|
-
"@nudojs/
|
|
28
|
+
"@nudojs/core": "0.3.0",
|
|
29
|
+
"@nudojs/service": "0.3.0"
|
|
18
30
|
},
|
|
19
31
|
"peerDependencies": {
|
|
20
32
|
"vite": ">=5.0.0"
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import nudoPlugin, { type NudoPluginOptions } from "../index.ts";
|
|
3
6
|
|
|
4
7
|
describe("vite-plugin-nudo", () => {
|
|
5
8
|
it("creates a plugin with correct name", () => {
|
|
@@ -7,19 +10,53 @@ describe("vite-plugin-nudo", () => {
|
|
|
7
10
|
expect(plugin.name).toBe("vite-plugin-nudo");
|
|
8
11
|
});
|
|
9
12
|
|
|
10
|
-
it("returns null for files without @nudo: directives", () => {
|
|
13
|
+
it("returns null for files without @nudo: directives", async () => {
|
|
11
14
|
const plugin = nudoPlugin();
|
|
12
|
-
const result = plugin.transform.call({}, "const x = 1;", "/test/file.js");
|
|
15
|
+
const result = await plugin.transform.call({}, "const x = 1;", "/test/file.js");
|
|
13
16
|
expect(result).toBeNull();
|
|
14
17
|
});
|
|
15
18
|
|
|
16
|
-
it("
|
|
19
|
+
it("analyzes files that only declare @nudo:refine (gate aligned with LSP)", async () => {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), "nudo-vite-"));
|
|
21
|
+
try {
|
|
22
|
+
writeFileSync(
|
|
23
|
+
join(dir, "shapes.nudo.js"),
|
|
24
|
+
`export const positive = number().gt(0);\n`,
|
|
25
|
+
"utf-8",
|
|
26
|
+
);
|
|
27
|
+
const source = `/// @nudo:import { positive } from "./shapes.nudo.js"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @nudo:refine x positive
|
|
31
|
+
*/
|
|
32
|
+
function needsPositive(x) {
|
|
33
|
+
return x;
|
|
34
|
+
}
|
|
35
|
+
const r = needsPositive(-1);
|
|
36
|
+
`;
|
|
37
|
+
const filePath = join(dir, "refine-only.js");
|
|
38
|
+
writeFileSync(filePath, source, "utf-8");
|
|
39
|
+
|
|
40
|
+
const plugin = nudoPlugin();
|
|
41
|
+
const warnFn = vi.fn();
|
|
42
|
+
const ctx = { warn: warnFn, error: vi.fn() };
|
|
43
|
+
const result = await plugin.transform.call(ctx, source, filePath);
|
|
44
|
+
expect(result).toBeNull();
|
|
45
|
+
expect(warnFn).toHaveBeenCalled();
|
|
46
|
+
const msgs = warnFn.mock.calls.map((c) => String(c[0])).join("\n");
|
|
47
|
+
expect(msgs).toContain("constraint-violated");
|
|
48
|
+
} finally {
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null for node_modules files", async () => {
|
|
17
54
|
const plugin = nudoPlugin();
|
|
18
|
-
const result = plugin.transform.call({}, "const x = 1;", "/node_modules/pkg/file.js");
|
|
55
|
+
const result = await plugin.transform.call({}, "const x = 1;", "/node_modules/pkg/file.js");
|
|
19
56
|
expect(result).toBeNull();
|
|
20
57
|
});
|
|
21
58
|
|
|
22
|
-
it("analyzes files with @nudo: directives and reports warnings", () => {
|
|
59
|
+
it("analyzes files with @nudo: directives and reports warnings", async () => {
|
|
23
60
|
const plugin = nudoPlugin();
|
|
24
61
|
const warnFn = vi.fn();
|
|
25
62
|
const ctx = { warn: warnFn, error: vi.fn() };
|
|
@@ -35,14 +72,14 @@ function safeSqrt(x) {
|
|
|
35
72
|
return x;
|
|
36
73
|
}
|
|
37
74
|
`;
|
|
38
|
-
const result = plugin.transform.call(ctx, source, "/test/throws.js");
|
|
75
|
+
const result = await plugin.transform.call(ctx, source, "/test/throws.js");
|
|
39
76
|
expect(result).toBeNull();
|
|
40
77
|
expect(warnFn).toHaveBeenCalled();
|
|
41
78
|
});
|
|
42
79
|
|
|
43
|
-
it("respects custom include patterns", () => {
|
|
80
|
+
it("respects custom include patterns", async () => {
|
|
44
81
|
const plugin = nudoPlugin({ include: ["**/*.typed.js"] });
|
|
45
|
-
const result = plugin.transform.call({}, "const x = 1;", "/test/file.js");
|
|
82
|
+
const result = await plugin.transform.call({}, "const x = 1;", "/test/file.js");
|
|
46
83
|
expect(result).toBeNull();
|
|
47
84
|
});
|
|
48
85
|
|
|
@@ -60,22 +97,99 @@ function safeSqrt(x) {
|
|
|
60
97
|
consoleSpy.mockRestore();
|
|
61
98
|
});
|
|
62
99
|
|
|
63
|
-
it("errors when failOnError is true and diagnostics have errors", () => {
|
|
100
|
+
it("errors when failOnError is true and diagnostics have errors", async () => {
|
|
64
101
|
const plugin = nudoPlugin({ failOnError: true });
|
|
65
102
|
const errorFn = vi.fn();
|
|
66
103
|
const warnFn = vi.fn();
|
|
67
104
|
const ctx = { warn: warnFn, error: errorFn };
|
|
68
105
|
|
|
106
|
+
// case 期望返回类型与推断不符 → error 级诊断
|
|
69
107
|
const source = `
|
|
70
108
|
/**
|
|
71
|
-
* @nudo:case "test" (T.
|
|
72
|
-
* @nudo:returns (T.string)
|
|
109
|
+
* @nudo:case "test" (1) => T.string
|
|
73
110
|
*/
|
|
74
111
|
function identity(x) {
|
|
75
112
|
return x;
|
|
76
113
|
}
|
|
77
114
|
`;
|
|
78
|
-
plugin.transform.call(ctx, source, "/test/fail.js");
|
|
115
|
+
await plugin.transform.call(ctx, source, "/test/fail.js");
|
|
79
116
|
expect(errorFn).toHaveBeenCalled();
|
|
80
117
|
});
|
|
81
118
|
});
|
|
119
|
+
|
|
120
|
+
describe("vite-plugin-nudo glob matching", () => {
|
|
121
|
+
// 稳定产出 warning(throw 路径),用来证明文件被分析过
|
|
122
|
+
const directiveSource = `
|
|
123
|
+
/**
|
|
124
|
+
* @nudo:case "negative" (-1)
|
|
125
|
+
*/
|
|
126
|
+
function safeSqrt(x) {
|
|
127
|
+
if (x < 0) {
|
|
128
|
+
throw new RangeError("negative input");
|
|
129
|
+
}
|
|
130
|
+
return x;
|
|
131
|
+
}
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
async function wasAnalyzed(id: string, options: NudoPluginOptions = {}): Promise<boolean> {
|
|
135
|
+
const plugin = nudoPlugin(options);
|
|
136
|
+
const warnFn = vi.fn();
|
|
137
|
+
const ctx = { warn: warnFn, error: vi.fn() };
|
|
138
|
+
const result = await plugin.transform.call(ctx, directiveSource, id);
|
|
139
|
+
return result === null && warnFn.mock.calls.length > 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
it("matches **/*.js at any depth by default", async () => {
|
|
143
|
+
expect(await wasAnalyzed("/file.js")).toBe(true);
|
|
144
|
+
expect(await wasAnalyzed("/project/src/nested/mod.js")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("default include covers js/mjs/ts but not cjs", async () => {
|
|
148
|
+
expect(await wasAnalyzed("/test/file.mjs")).toBe(true);
|
|
149
|
+
expect(await wasAnalyzed("/test/file.ts")).toBe(true);
|
|
150
|
+
expect(await wasAnalyzed("/test/file.cjs")).toBe(false);
|
|
151
|
+
expect(await wasAnalyzed("/test/file.d.ts")).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("supports **/*.mjs include patterns", async () => {
|
|
155
|
+
const options = { include: ["**/*.mjs"] };
|
|
156
|
+
expect(await wasAnalyzed("/test/file.mjs", options)).toBe(true);
|
|
157
|
+
expect(await wasAnalyzed("/project/src/deep/file.mjs", options)).toBe(true);
|
|
158
|
+
expect(await wasAnalyzed("/test/file.js", options)).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("supports **/*.ts include patterns", async () => {
|
|
162
|
+
const options = { include: ["**/*.ts"] };
|
|
163
|
+
expect(await wasAnalyzed("/src/util.ts", options)).toBe(true);
|
|
164
|
+
expect(await wasAnalyzed("/src/util.js", options)).toBe(false);
|
|
165
|
+
expect(await wasAnalyzed("/src/component.tsx", options)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("excludes **/node_modules/** at any depth by default", async () => {
|
|
169
|
+
expect(await wasAnalyzed("/project/node_modules/pkg/index.js")).toBe(false);
|
|
170
|
+
expect(await wasAnalyzed("/project/packages/a/node_modules/dep/lib.js")).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("excludes **/<dir>/** directory segments without false positives", async () => {
|
|
174
|
+
const options = { include: ["**/*.js"], exclude: ["**/fixtures/**"] };
|
|
175
|
+
expect(await wasAnalyzed("/test/fixtures/case.js", options)).toBe(false);
|
|
176
|
+
expect(await wasAnalyzed("/test/case.js", options)).toBe(true);
|
|
177
|
+
// "my-fixtures" is not the "fixtures" segment, so it must stay included.
|
|
178
|
+
expect(await wasAnalyzed("/test/my-fixtures/case.js", options)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("gives exclude precedence over include", async () => {
|
|
182
|
+
const options = { include: ["**/*.js"], exclude: ["**/vendor/**"] };
|
|
183
|
+
expect(await wasAnalyzed("/src/vendor/lib.js", options)).toBe(false);
|
|
184
|
+
expect(await wasAnalyzed("/src/lib.js", options)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("falls back to literal substring matching for wildcard-free patterns", async () => {
|
|
188
|
+
const options = { include: ["generated"] };
|
|
189
|
+
expect(await wasAnalyzed("/src/generated/helpers.js", options)).toBe(true);
|
|
190
|
+
expect(await wasAnalyzed("/src/helpers.js", options)).toBe(false);
|
|
191
|
+
|
|
192
|
+
const excluded = { include: ["**/*.js"], exclude: ["snapshots"] };
|
|
193
|
+
expect(await wasAnalyzed("/src/snapshots/old.js", excluded)).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
analyzeFileAsync,
|
|
3
|
+
defaultLoadModule as loadModule,
|
|
4
|
+
clearAnalysisSessionCaches,
|
|
5
|
+
type AnalysisResult,
|
|
6
|
+
type Diagnostic,
|
|
7
|
+
} from "@nudojs/service";
|
|
8
|
+
import { checkSource, pTrue } from "@nudojs/core";
|
|
2
9
|
|
|
3
10
|
export type NudoPluginOptions = {
|
|
4
11
|
include?: string[];
|
|
@@ -6,27 +13,103 @@ export type NudoPluginOptions = {
|
|
|
6
13
|
failOnError?: boolean;
|
|
7
14
|
};
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
const
|
|
16
|
+
/** 与 LSP hasNudoDirectives 对齐:含 refine/import,避免漏掉契约文件 */
|
|
17
|
+
const NUDO_DIRECTIVE_RE =
|
|
18
|
+
/@nudo:(case|mock|pure|skip|sample|refine|import|env|mock-module|as|replace)\b/;
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
/** Abs check issues → service Diagnostic(与 evaluator 诊断同管道进 vite warn/error) */
|
|
21
|
+
function checkIssuesToDiagnostics(id: string, code: string): Diagnostic[] {
|
|
22
|
+
try {
|
|
23
|
+
const report = checkSource(id, code, pTrue, { loadModule, fromFile: id });
|
|
24
|
+
return report.issues
|
|
25
|
+
.filter((i) => i.severity === "error" || i.severity === "warning")
|
|
26
|
+
.map((i) => {
|
|
27
|
+
const line = i.line ?? 1;
|
|
28
|
+
const column = i.column ?? 0;
|
|
29
|
+
const parts = [i.message];
|
|
30
|
+
if (i.actual) parts.push(`actual: ${i.actual}`);
|
|
31
|
+
if (i.expected) parts.push(`expected: ${i.expected}`);
|
|
32
|
+
if (i.suggestion) parts.push(i.suggestion);
|
|
33
|
+
if (i.code) parts.push(`(${i.code})`);
|
|
34
|
+
return {
|
|
35
|
+
severity: i.severity === "error" ? ("error" as const) : ("warning" as const),
|
|
36
|
+
message: parts.join(" · "),
|
|
37
|
+
code: i.code,
|
|
38
|
+
range: {
|
|
39
|
+
start: { line, column },
|
|
40
|
+
end: { line, column: column + 1 },
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
16
47
|
}
|
|
17
48
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
49
|
+
const DEFAULT_INCLUDE = ["**/*.js", "**/*.mjs", "**/*.ts", "**/*.mts"];
|
|
50
|
+
const DEFAULT_EXCLUDE = ["**/node_modules/**", "**/*.d.ts"];
|
|
51
|
+
|
|
52
|
+
type Matcher = (id: string) => boolean;
|
|
53
|
+
|
|
54
|
+
const REGEX_SPECIALS = /[\\^$.|?*+(){}\[\]]/;
|
|
55
|
+
|
|
56
|
+
function escapeRegExpChar(ch: string): string {
|
|
57
|
+
return REGEX_SPECIALS.test(ch) ? `\\${ch}` : ch;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Translate one path segment (`**` segments are handled by the caller). */
|
|
61
|
+
function segmentToRegExpSource(segment: string): string {
|
|
62
|
+
let source = "";
|
|
63
|
+
for (const ch of segment) {
|
|
64
|
+
if (ch === "*") source += "[^/]*";
|
|
65
|
+
else if (ch === "?") source += "[^/]";
|
|
66
|
+
else source += escapeRegExpChar(ch);
|
|
23
67
|
}
|
|
24
|
-
return
|
|
68
|
+
return source;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Compile a glob pattern into an anchored RegExp. Supported syntax:
|
|
72
|
+
// - a `**` segment: zero or more path segments, e.g. `**` + `/*.mjs` or `**` + `/node_modules/**`
|
|
73
|
+
// - `*` / `?` inside a segment, never crossing `/`
|
|
74
|
+
// - all other characters matched literally (regex specials are escaped)
|
|
75
|
+
// Patterns without wildcards are treated as literal substrings by the caller.
|
|
76
|
+
function patternToRegExp(pattern: string): RegExp {
|
|
77
|
+
const segments = pattern.split("/");
|
|
78
|
+
let source = "";
|
|
79
|
+
for (let i = 0; i < segments.length; i++) {
|
|
80
|
+
const segment = segments[i];
|
|
81
|
+
const isLast = i === segments.length - 1;
|
|
82
|
+
if (segment === "**") {
|
|
83
|
+
if (isLast) {
|
|
84
|
+
source = source.endsWith("/")
|
|
85
|
+
? `${source.slice(0, -1)}(?:/.*)?`
|
|
86
|
+
: `${source}.*`;
|
|
87
|
+
} else {
|
|
88
|
+
source += "(?:.*/)?";
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
source += segmentToRegExpSource(segment);
|
|
92
|
+
if (!isLast) source += "/";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return new RegExp(`^${source}$`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function compilePattern(pattern: string): Matcher {
|
|
99
|
+
if (!/[*?]/.test(pattern)) return (id) => id.includes(pattern);
|
|
100
|
+
const regExp = patternToRegExp(pattern);
|
|
101
|
+
return (id) => regExp.test(id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Compile a pattern list into a matcher that is true when any pattern matches. */
|
|
105
|
+
function compileAnyMatcher(patterns: string[]): Matcher {
|
|
106
|
+
const matchers = patterns.map(compilePattern);
|
|
107
|
+
return (id) => matchers.some((match) => match(id));
|
|
25
108
|
}
|
|
26
109
|
|
|
27
110
|
export default function nudoPlugin(options: NudoPluginOptions = {}): any {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
111
|
+
const includeMatch = compileAnyMatcher(options.include ?? DEFAULT_INCLUDE);
|
|
112
|
+
const excludeMatch = compileAnyMatcher(options.exclude ?? DEFAULT_EXCLUDE);
|
|
30
113
|
const failOnError = options.failOnError ?? false;
|
|
31
114
|
|
|
32
115
|
const analysisCache = new Map<string, AnalysisResult>();
|
|
@@ -36,17 +119,29 @@ export default function nudoPlugin(options: NudoPluginOptions = {}): any {
|
|
|
36
119
|
|
|
37
120
|
buildStart() {
|
|
38
121
|
analysisCache.clear();
|
|
122
|
+
// 全新构建:丢掉上一轮会话 memo,避免跨 build 陈旧命中
|
|
123
|
+
clearAnalysisSessionCaches();
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/** 任一文件变更后,未改 source 的 importer 再 transform 时可能命中陈旧会话缓存 */
|
|
127
|
+
watchChange() {
|
|
128
|
+
analysisCache.clear();
|
|
129
|
+
clearAnalysisSessionCaches();
|
|
39
130
|
},
|
|
40
131
|
|
|
41
|
-
transform(code: string, id: string) {
|
|
42
|
-
if (
|
|
43
|
-
if (
|
|
132
|
+
async transform(code: string, id: string) {
|
|
133
|
+
if (excludeMatch(id)) return null;
|
|
134
|
+
if (!includeMatch(id)) return null;
|
|
135
|
+
if (!NUDO_DIRECTIVE_RE.test(code)) return null;
|
|
44
136
|
|
|
45
137
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
138
|
+
// async 以便 path 型 @nudo:env 预加载(与 LSP analyzeFileAsync 对齐)
|
|
139
|
+
const result = await analyzeFileAsync(id, code);
|
|
140
|
+
const checkDiags = checkIssuesToDiagnostics(id, code);
|
|
141
|
+
const merged = { ...result, diagnostics: [...result.diagnostics, ...checkDiags] };
|
|
142
|
+
analysisCache.set(id, merged);
|
|
48
143
|
|
|
49
|
-
for (const diag of
|
|
144
|
+
for (const diag of merged.diagnostics) {
|
|
50
145
|
const loc = `${id}:${diag.range.start.line}:${diag.range.start.column}`;
|
|
51
146
|
const msg = `[nudo] ${loc} ${diag.severity}: ${diag.message}`;
|
|
52
147
|
|