vite-plugin-nudo 0.2.1 → 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 CHANGED
@@ -1,5 +1,23 @@
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
+
3
21
  ## 0.2.1
4
22
 
5
23
  ### Patch Changes
package/dist/index.js CHANGED
@@ -1,37 +1,105 @@
1
1
  // src/index.ts
2
- import { analyzeFile } from "@nudojs/service";
3
- var DEFAULT_INCLUDE = ["**/*.js"];
4
- var DEFAULT_EXCLUDE = ["**/node_modules/**"];
5
- function matchesPatterns(id, include, exclude) {
6
- const isExcluded = exclude.some((p) => minimatch(id, p));
7
- if (isExcluded) return false;
8
- return include.some((p) => minimatch(id, p));
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 minimatch(str, pattern) {
11
- if (pattern === "**/*.js") return str.endsWith(".js");
12
- if (pattern === "**/node_modules/**") return str.includes("/node_modules/");
13
- if (pattern.startsWith("**/")) {
14
- return str.endsWith(pattern.slice(3)) || str.includes(pattern.slice(2));
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 str.includes(pattern);
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 include = options.include ?? DEFAULT_INCLUDE;
20
- const exclude = options.exclude ?? DEFAULT_EXCLUDE;
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 (!matchesPatterns(id, include, exclude)) return null;
30
- if (!/@nudo:(case|mock|pure|skip|sample|returns|env|mock-module|as|replace)\b/.test(code)) return null;
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 = analyzeFile(id, code);
33
- analysisCache.set(id, result);
34
- for (const diag of result.diagnostics) {
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,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-nudo",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Vite plugin for build-time JavaScript type inference with Nudo",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,8 @@
25
25
  "registry": "https://registry.npmjs.org"
26
26
  },
27
27
  "dependencies": {
28
- "@nudojs/service": "0.2.1"
28
+ "@nudojs/core": "0.3.0",
29
+ "@nudojs/service": "0.3.0"
29
30
  },
30
31
  "peerDependencies": {
31
32
  "vite": ">=5.0.0"
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import nudoPlugin from "../index.ts";
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("returns null for node_modules files", () => {
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.number)
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 { analyzeFile, type AnalysisResult } from "@nudojs/service";
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
- const DEFAULT_INCLUDE = ["**/*.js"];
10
- const DEFAULT_EXCLUDE = ["**/node_modules/**"];
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
- function matchesPatterns(id: string, include: string[], exclude: string[]): boolean {
13
- const isExcluded = exclude.some((p) => minimatch(id, p));
14
- if (isExcluded) return false;
15
- return include.some((p) => minimatch(id, p));
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
- function minimatch(str: string, pattern: string): boolean {
19
- if (pattern === "**/*.js") return str.endsWith(".js");
20
- if (pattern === "**/node_modules/**") return str.includes("/node_modules/");
21
- if (pattern.startsWith("**/")) {
22
- return str.endsWith(pattern.slice(3)) || str.includes(pattern.slice(2));
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 str.includes(pattern);
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 include = options.include ?? DEFAULT_INCLUDE;
29
- const exclude = options.exclude ?? DEFAULT_EXCLUDE;
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 (!matchesPatterns(id, include, exclude)) return null;
43
- if (!/@nudo:(case|mock|pure|skip|sample|returns|env|mock-module|as|replace)\b/.test(code)) return null;
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
- const result = analyzeFile(id, code);
47
- analysisCache.set(id, result);
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 result.diagnostics) {
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