@nudojs/service 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 +37 -0
- package/README.md +27 -0
- package/dist/index.d.ts +638 -5
- package/dist/index.js +4439 -208
- package/package.json +17 -4
- package/src/__tests__/abs-bindings.test.ts +53 -0
- package/src/__tests__/abs-call-records.test.ts +33 -0
- package/src/__tests__/abs-calls.test.ts +59 -0
- package/src/__tests__/abs-module-cache.test.ts +121 -0
- package/src/__tests__/abs-modules-graph.test.ts +100 -0
- package/src/__tests__/abs-typevalue-diff.test.ts +111 -0
- package/src/__tests__/analyzer.test.ts +745 -2
- package/src/__tests__/as-replace.test.ts +165 -0
- package/src/__tests__/bpath-arg-provenance.test.ts +104 -0
- package/src/__tests__/bpath-as.test.ts +54 -0
- package/src/__tests__/bpath-cache-key.test.ts +33 -0
- package/src/__tests__/bpath-calls.test.ts +55 -0
- package/src/__tests__/bpath-diagnostics.test.ts +78 -0
- package/src/__tests__/bpath-env-modules.test.ts +30 -0
- package/src/__tests__/bpath-env.test.ts +80 -0
- package/src/__tests__/bpath-hosted-skip.test.ts +71 -0
- package/src/__tests__/bpath-import-default-ns.test.ts +106 -0
- package/src/__tests__/bpath-method-dedupe.test.ts +65 -0
- package/src/__tests__/bpath-method-diag.test.ts +45 -0
- package/src/__tests__/bpath-module-diags.test.ts +101 -0
- package/src/__tests__/bpath-path-env.test.ts +46 -0
- package/src/__tests__/bpath-reexport.test.ts +131 -0
- package/src/__tests__/bpath-replace.test.ts +52 -0
- package/src/__tests__/bpath-require.test.ts +66 -0
- package/src/__tests__/bpath-run.test.ts +109 -0
- package/src/__tests__/case-emitter.test.ts +636 -0
- package/src/__tests__/cjs-require.test.ts +54 -0
- package/src/__tests__/diagnostics.test.ts +60 -0
- package/src/__tests__/dts-generator.test.ts +290 -6
- package/src/__tests__/fn-cache-evict.test.ts +56 -0
- package/src/__tests__/fn-sig-impl.test.ts +247 -0
- package/src/__tests__/guard-generator.test.ts +29 -0
- package/src/__tests__/harvest-auto.test.ts +43 -0
- package/src/__tests__/harvest-node.test.ts +24 -0
- package/src/__tests__/harvest-package.test.ts +38 -0
- package/src/__tests__/harvest-to-abs.test.ts +46 -0
- package/src/__tests__/hover-intension.test.ts +39 -0
- package/src/__tests__/infer-json.test.ts +35 -0
- package/src/__tests__/integration.test.ts +421 -0
- package/src/__tests__/m3-intension.test.ts +28 -0
- package/src/__tests__/mock-seed-fingerprint.test.ts +64 -0
- package/src/__tests__/schema-generator.test.ts +38 -0
- package/src/__tests__/session-cache-evict.test.ts +90 -0
- package/src/__tests__/static-imports.test.ts +44 -0
- package/src/abs-modules-graph.ts +387 -0
- package/src/analysis-file-cache.ts +51 -0
- package/src/analyzer.ts +2569 -94
- package/src/bpath-diagnostics.ts +283 -0
- package/src/bpath-run.ts +409 -0
- package/src/case-emitter.ts +477 -0
- package/src/dts-generator.ts +220 -24
- package/src/env-to-abs.ts +57 -0
- package/src/fn-analysis-cache.ts +119 -0
- package/src/guard-generator.ts +57 -0
- package/src/harvest-auto.ts +86 -0
- package/src/harvest-node.ts +48 -0
- package/src/harvest-package.ts +204 -0
- package/src/harvest-to-abs.ts +129 -0
- package/src/index.ts +128 -0
- package/src/infer-json.ts +137 -0
- package/src/load-module.ts +34 -0
- package/src/mock-abs.ts +275 -0
- package/src/schema-generator.ts +43 -0
- package/src/semantic-tokens.ts +216 -0
- package/src/session-cache.ts +50 -0
- package/src/static-imports.ts +241 -0
- package/src/target-path.ts +17 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { numLit, unknown } from "@nudojs/core";
|
|
3
|
+
import { parseTypeValueExpr } from "@nudojs/parser";
|
|
4
|
+
import { mockSeedFingerprint } from "../bpath-run.ts";
|
|
5
|
+
import { mockDirectivesToAbsSeeds } from "../mock-abs.ts";
|
|
6
|
+
|
|
7
|
+
describe("mockSeedFingerprint", () => {
|
|
8
|
+
it("misses when mock values change under the same names", () => {
|
|
9
|
+
const a = mockSeedFingerprint({ fetch: numLit(1) });
|
|
10
|
+
const b = mockSeedFingerprint({ fetch: numLit(2) });
|
|
11
|
+
expect(a).not.toBe(b);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("hits for identical name+value sets regardless of key order", () => {
|
|
15
|
+
const a = mockSeedFingerprint({ x: numLit(1), y: unknown });
|
|
16
|
+
const b = mockSeedFingerprint({ y: unknown, x: numLit(1) });
|
|
17
|
+
expect(a).toBe(b);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("empty and missing mocks share the dash key", () => {
|
|
21
|
+
expect(mockSeedFingerprint(undefined)).toBe("-");
|
|
22
|
+
expect(mockSeedFingerprint({})).toBe("-");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("misses when real sinon.stub().returns value changes", () => {
|
|
26
|
+
const seeds1 = mockDirectivesToAbsSeeds([
|
|
27
|
+
{
|
|
28
|
+
directives: [
|
|
29
|
+
{
|
|
30
|
+
kind: "mock",
|
|
31
|
+
name: "fetch",
|
|
32
|
+
sinonExpr: { type: "sinon.stub", returnValue: parseTypeValueExpr("1") },
|
|
33
|
+
} as never,
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
37
|
+
const seeds2 = mockDirectivesToAbsSeeds([
|
|
38
|
+
{
|
|
39
|
+
directives: [
|
|
40
|
+
{
|
|
41
|
+
kind: "mock",
|
|
42
|
+
name: "fetch",
|
|
43
|
+
sinonExpr: { type: "sinon.stub", returnValue: parseTypeValueExpr("2") },
|
|
44
|
+
} as never,
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
const a = mockSeedFingerprint(seeds1.seedVars, seeds1.seedFns);
|
|
49
|
+
const b = mockSeedFingerprint(seeds2.seedVars, seeds2.seedFns);
|
|
50
|
+
expect(a).not.toBe(b);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("includes seedFns in the fingerprint", () => {
|
|
54
|
+
const a = mockSeedFingerprint(
|
|
55
|
+
{},
|
|
56
|
+
{ load: { params: ["x"], body: { type: "Identifier", name: "x" } as never } },
|
|
57
|
+
);
|
|
58
|
+
const b = mockSeedFingerprint(
|
|
59
|
+
{},
|
|
60
|
+
{ load: { params: ["y"], body: { type: "Identifier", name: "y" } as never } },
|
|
61
|
+
);
|
|
62
|
+
expect(a).not.toBe(b);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { T } from "@nudojs/core";
|
|
3
|
+
import { typeValueToZodSchema } from "../schema-generator.ts";
|
|
4
|
+
|
|
5
|
+
describe("schema-generator", () => {
|
|
6
|
+
it("generates z.string() for string type", () => {
|
|
7
|
+
expect(typeValueToZodSchema(T.string)).toBe("z.string()");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("generates z.number() for number type", () => {
|
|
11
|
+
expect(typeValueToZodSchema(T.number)).toBe("z.number()");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("generates z.literal() for literal types", () => {
|
|
15
|
+
expect(typeValueToZodSchema(T.literal("hello"))).toBe('z.literal("hello")');
|
|
16
|
+
expect(typeValueToZodSchema(T.literal(42))).toBe("z.literal(42)");
|
|
17
|
+
expect(typeValueToZodSchema(T.literal(true))).toBe("z.literal(true)");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("generates z.object() for object types", () => {
|
|
21
|
+
const obj = T.object({ name: T.string, age: T.number });
|
|
22
|
+
expect(typeValueToZodSchema(obj)).toBe("z.object({ name: z.string(), age: z.number() })");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("generates z.array() for array types", () => {
|
|
26
|
+
expect(typeValueToZodSchema(T.array(T.string))).toBe("z.array(z.string())");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("generates z.union() for union types", () => {
|
|
30
|
+
const union = T.union(T.string, T.number);
|
|
31
|
+
expect(typeValueToZodSchema(union)).toBe("z.union([z.string(), z.number()])");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("generates z.null() and z.undefined()", () => {
|
|
35
|
+
expect(typeValueToZodSchema(T.null)).toBe("z.null()");
|
|
36
|
+
expect(typeValueToZodSchema(T.undefined)).toBe("z.undefined()");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主契约回归:入口 source 未变、依赖模块内容变了时,
|
|
3
|
+
* 必须 evictAnalysisCachesForFiles(CLI watch / vite-plugin),
|
|
4
|
+
* 否则 AnalysisResult / B-path / fn-cache 会命中陈旧结果。
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
7
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
analyzeFile,
|
|
12
|
+
clearAnalysisSessionCaches,
|
|
13
|
+
evictAnalysisCachesForFiles,
|
|
14
|
+
} from "@nudojs/service";
|
|
15
|
+
import { typeValueToString } from "@nudojs/core";
|
|
16
|
+
|
|
17
|
+
const dirs: string[] = [];
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
clearAnalysisSessionCaches();
|
|
20
|
+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function caseResultOf(result: ReturnType<typeof analyzeFile>, fnName: string, caseName: string): string {
|
|
24
|
+
const fn = result.functions.find((f) => f.name === fnName);
|
|
25
|
+
const c = fn?.cases.find((x) => x.name === caseName);
|
|
26
|
+
if (!c) throw new Error(`missing case ${fnName}/${caseName}`);
|
|
27
|
+
return typeValueToString(c.result);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("dep-change host eviction contract", () => {
|
|
31
|
+
const MAIN = `
|
|
32
|
+
const util = require("./util.js");
|
|
33
|
+
/**
|
|
34
|
+
* @nudo:case "t" (1)
|
|
35
|
+
*/
|
|
36
|
+
function go(n) {
|
|
37
|
+
return util.inc(n);
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
it("without eviction, parent analysis hits stale result after dep change", () => {
|
|
42
|
+
clearAnalysisSessionCaches();
|
|
43
|
+
const dir = mkdtempSync(join(tmpdir(), "nudo-stale-"));
|
|
44
|
+
dirs.push(dir);
|
|
45
|
+
const depPath = join(dir, "util.js");
|
|
46
|
+
const mainPath = join(dir, "main.js");
|
|
47
|
+
writeFileSync(depPath, `export function inc(x) { return x + 1; }\n`);
|
|
48
|
+
writeFileSync(mainPath, MAIN);
|
|
49
|
+
|
|
50
|
+
const r1 = analyzeFile(mainPath, MAIN);
|
|
51
|
+
expect(caseResultOf(r1, "go", "t")).toBe("2");
|
|
52
|
+
|
|
53
|
+
// dep 变了,parent source 不变;不逐出 → 整文件缓存命中
|
|
54
|
+
writeFileSync(depPath, `export function inc(x) { return x + 99; }\n`);
|
|
55
|
+
const r2 = analyzeFile(mainPath, MAIN);
|
|
56
|
+
expect(caseResultOf(r2, "go", "t")).toBe("2"); // 陈旧——正是契约要防的
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("evictAnalysisCachesForFiles makes parent see dep change", () => {
|
|
60
|
+
clearAnalysisSessionCaches();
|
|
61
|
+
const dir = mkdtempSync(join(tmpdir(), "nudo-evict-"));
|
|
62
|
+
dirs.push(dir);
|
|
63
|
+
const depPath = join(dir, "util.js");
|
|
64
|
+
const mainPath = join(dir, "main.js");
|
|
65
|
+
writeFileSync(depPath, `export function inc(x) { return x + 1; }\n`);
|
|
66
|
+
writeFileSync(mainPath, MAIN);
|
|
67
|
+
|
|
68
|
+
expect(caseResultOf(analyzeFile(mainPath, MAIN), "go", "t")).toBe("2");
|
|
69
|
+
|
|
70
|
+
writeFileSync(depPath, `export function inc(x) { return x + 99; }\n`);
|
|
71
|
+
// 模拟 CLI watch:脏集入口文件定向逐出
|
|
72
|
+
evictAnalysisCachesForFiles([mainPath]);
|
|
73
|
+
expect(caseResultOf(analyzeFile(mainPath, MAIN), "go", "t")).toBe("100");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("clearAnalysisSessionCaches is a full reset", () => {
|
|
77
|
+
clearAnalysisSessionCaches();
|
|
78
|
+
const dir = mkdtempSync(join(tmpdir(), "nudo-clear-"));
|
|
79
|
+
dirs.push(dir);
|
|
80
|
+
const depPath = join(dir, "util.js");
|
|
81
|
+
const mainPath = join(dir, "main.js");
|
|
82
|
+
writeFileSync(depPath, `export function inc(x) { return x + 1; }\n`);
|
|
83
|
+
writeFileSync(mainPath, MAIN);
|
|
84
|
+
|
|
85
|
+
expect(caseResultOf(analyzeFile(mainPath, MAIN), "go", "t")).toBe("2");
|
|
86
|
+
writeFileSync(depPath, `export function inc(x) { return x + 5; }\n`);
|
|
87
|
+
clearAnalysisSessionCaches();
|
|
88
|
+
expect(caseResultOf(analyzeFile(mainPath, MAIN), "go", "t")).toBe("6");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { collectStaticImports } from "../static-imports.ts";
|
|
6
|
+
|
|
7
|
+
describe("host static imports (not a bundler)", () => {
|
|
8
|
+
it("collects relative imports for type facts", () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "nudo-imp-"));
|
|
10
|
+
try {
|
|
11
|
+
writeFileSync(
|
|
12
|
+
join(dir, "math.js"),
|
|
13
|
+
`export function add(a, b) { return a + b; }\nexport function scale(x) { return add(x, 1); }\n`,
|
|
14
|
+
);
|
|
15
|
+
writeFileSync(
|
|
16
|
+
join(dir, "index.js"),
|
|
17
|
+
`import { add, scale } from "./math.js";\nexport function double(x) { return scale(x) + x; }\n`,
|
|
18
|
+
);
|
|
19
|
+
const graph = collectStaticImports(join(dir, "index.js"));
|
|
20
|
+
expect(graph.size).toBe(2);
|
|
21
|
+
const index = graph.get(join(dir, "index.js"))!;
|
|
22
|
+
expect(index.named.has("double")).toBe(true);
|
|
23
|
+
expect(index.poly.get("double")?.display).toContain("double");
|
|
24
|
+
const math = graph.get(join(dir, "math.js"))!;
|
|
25
|
+
expect(math.poly.get("add")?.display).toContain("A1");
|
|
26
|
+
} finally {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("skips bare specifiers (npm packages)", () => {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "nudo-bare-"));
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(dir, "app.js"),
|
|
36
|
+
`import lodash from "lodash";\nexport function f(x) { return x + 1; }\n`,
|
|
37
|
+
);
|
|
38
|
+
const graph = collectStaticImports(join(dir, "app.js"));
|
|
39
|
+
expect(graph.size).toBe(1);
|
|
40
|
+
} finally {
|
|
41
|
+
rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host:import 图(相对 + 裸包 harvest)→ Abs 导出表 → 注入入口。
|
|
3
|
+
* 读 fs / 解析路径 / harvest 在这里;core 只收 modules 表。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
|
+
import { parse } from "@nudojs/parser";
|
|
9
|
+
import {
|
|
10
|
+
evalProgramAbs,
|
|
11
|
+
collectAbsExports,
|
|
12
|
+
absFunction,
|
|
13
|
+
type Abs,
|
|
14
|
+
type AbsModuleExports,
|
|
15
|
+
type AstEnv,
|
|
16
|
+
type Phi,
|
|
17
|
+
} from "@nudojs/core";
|
|
18
|
+
import type { Node } from "@babel/types";
|
|
19
|
+
import { bareSpecToAbsModules } from "./harvest-to-abs.ts";
|
|
20
|
+
|
|
21
|
+
export type AbsLoadModule = (spec: string, fromFile: string) => string | undefined;
|
|
22
|
+
|
|
23
|
+
/** 相对说明符 → 源码 */
|
|
24
|
+
export function defaultAbsLoadModule(spec: string, fromFile: string): string | undefined {
|
|
25
|
+
if (!spec.startsWith(".") && !spec.startsWith("/")) return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const p = resolve(dirname(resolve(fromFile)), spec);
|
|
28
|
+
for (const cand of [p, `${p}.js`, `${p}.mjs`, `${p}.ts`, resolve(p, "index.js")]) {
|
|
29
|
+
try {
|
|
30
|
+
return readFileSync(cand, "utf-8");
|
|
31
|
+
} catch {
|
|
32
|
+
/* next */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function relCandidates(spec: string, fromFile: string): string[] {
|
|
42
|
+
const p = resolve(dirname(resolve(fromFile)), spec);
|
|
43
|
+
return [p, `${p}.js`, `${p}.mjs`, `${p}.ts`, resolve(p, "index.js")];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveRel(spec: string, fromFile: string): string | null {
|
|
47
|
+
if (!spec.startsWith(".") && !spec.startsWith("/")) return null;
|
|
48
|
+
for (const cand of relCandidates(spec, fromFile)) {
|
|
49
|
+
try {
|
|
50
|
+
readFileSync(cand, "utf-8");
|
|
51
|
+
return cand;
|
|
52
|
+
} catch {
|
|
53
|
+
/* next */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function importSpecs(source: string): string[] {
|
|
60
|
+
try {
|
|
61
|
+
const file = parse(source);
|
|
62
|
+
const out: string[] = [];
|
|
63
|
+
for (const stmt of file.program.body) {
|
|
64
|
+
if (
|
|
65
|
+
stmt.type === "ImportDeclaration" ||
|
|
66
|
+
stmt.type === "ExportNamedDeclaration" ||
|
|
67
|
+
stmt.type === "ExportAllDeclaration"
|
|
68
|
+
) {
|
|
69
|
+
const src = (stmt as { source?: { value: string } }).source;
|
|
70
|
+
if (src && typeof src.value === "string") {
|
|
71
|
+
out.push(src.value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// require("...") / require('...')
|
|
75
|
+
collectRequireSpecs(stmt as never, out);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function collectRequireSpecs(node: unknown, out: string[]): void {
|
|
84
|
+
const visit = (n: unknown) => {
|
|
85
|
+
if (!n || typeof n !== "object") return;
|
|
86
|
+
const o = n as {
|
|
87
|
+
type?: string;
|
|
88
|
+
callee?: { type?: string; name?: string };
|
|
89
|
+
arguments?: Array<{ type?: string; value?: unknown }>;
|
|
90
|
+
[k: string]: unknown;
|
|
91
|
+
};
|
|
92
|
+
if (
|
|
93
|
+
o.type === "CallExpression" &&
|
|
94
|
+
o.callee?.type === "Identifier" &&
|
|
95
|
+
o.callee.name === "require" &&
|
|
96
|
+
o.arguments?.[0]?.type === "StringLiteral"
|
|
97
|
+
) {
|
|
98
|
+
const spec = o.arguments[0].value;
|
|
99
|
+
if (typeof spec === "string") out.push(spec);
|
|
100
|
+
}
|
|
101
|
+
for (const key of Object.keys(o)) {
|
|
102
|
+
if (key === "loc" || key === "start" || key === "end") continue;
|
|
103
|
+
const v = o[key];
|
|
104
|
+
if (Array.isArray(v)) v.forEach(visit);
|
|
105
|
+
else if (v && typeof v === "object") visit(v);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
visit(node);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 把当前文件的全部 import(相对 + 裸包)编成 modules 表 */
|
|
112
|
+
function buildModulesForFile(
|
|
113
|
+
source: string,
|
|
114
|
+
fromFile: string,
|
|
115
|
+
load: AbsLoadModule,
|
|
116
|
+
evalDep: (absPath: string, spec: string, fromFile: string, depth: number) => AbsModuleExports,
|
|
117
|
+
depth: number,
|
|
118
|
+
maxDepth: number,
|
|
119
|
+
onMissing: (spec: string, fromFile: string, tried: string[]) => void,
|
|
120
|
+
): Record<string, AbsModuleExports> {
|
|
121
|
+
const modules: Record<string, AbsModuleExports> = {};
|
|
122
|
+
for (const spec of importSpecs(source)) {
|
|
123
|
+
if (spec.startsWith(".") || spec.startsWith("/")) {
|
|
124
|
+
const childPath = resolveRel(spec, fromFile);
|
|
125
|
+
if (!childPath) {
|
|
126
|
+
onMissing(spec, fromFile, relCandidates(spec, fromFile));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
modules[spec] = evalDep(childPath, spec, fromFile, depth + 1);
|
|
130
|
+
} else if (!spec.startsWith("node:")) {
|
|
131
|
+
const bare = bareSpecToAbsModules(spec, fromFile);
|
|
132
|
+
if (bare) modules[spec] = bare;
|
|
133
|
+
// 裸包 harvest 失败 ≠ 文件缺失(可能是未覆盖的包形态),不报 missing
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
void maxDepth;
|
|
137
|
+
return modules;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 模块加载守卫:与 TypeValue loadModuleEnv 口径对齐,供 analyzer 映射诊断 */
|
|
141
|
+
export type AbsModuleLoadIssue = {
|
|
142
|
+
kind: "cycle" | "depth" | "missing";
|
|
143
|
+
/** 诊断定位用标签(文件 basename 或 require/import 说明符) */
|
|
144
|
+
label: string;
|
|
145
|
+
reason: string;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export type AbsModuleGraphResult = {
|
|
149
|
+
/** 入口 import 说明符 → 依赖导出表 */
|
|
150
|
+
modules: Record<string, AbsModuleExports>;
|
|
151
|
+
/** 绝对路径 → 导出表(含依赖;循环时占位为空) */
|
|
152
|
+
byPath: Map<string, AbsModuleExports>;
|
|
153
|
+
/** cycle / depth / missing(B 路径权威,避免 TypeValue 叠报) */
|
|
154
|
+
issues: AbsModuleLoadIssue[];
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type AbsGraphOptions = {
|
|
158
|
+
loadModule?: AbsLoadModule;
|
|
159
|
+
seedVars?: Record<string, Abs>;
|
|
160
|
+
seedFns?: Record<string, { params: string[]; body: Node; async?: boolean }>;
|
|
161
|
+
maxDepth?: number;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** 会话级依赖模块缓存条目:stat 指纹 + 导出 + 子树装载 issue。 */
|
|
165
|
+
export type AbsModuleCacheEntry = {
|
|
166
|
+
mtimeMs: number;
|
|
167
|
+
size: number;
|
|
168
|
+
exports: AbsModuleExports;
|
|
169
|
+
/** 该模块子树首次求值时记录的 cycle/depth/missing;命中时重放。 */
|
|
170
|
+
issues: AbsModuleLoadIssue[];
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 跨入口复用的依赖模块缓存(LSP 脏传播重验 N 个入口、共享同一依赖树时,
|
|
175
|
+
* 避免每个入口重复 parse + 抽象求值全部依赖)。键为解析后的绝对路径,
|
|
176
|
+
* 命中条件 mtimeMs+size 严格相等(与 ModuleGraphCache 边缓存同口径);
|
|
177
|
+
* 内容变更由指纹自然失效,删除经 evictAbsModuleCacheFiles / clearAbsModuleCache 逐出
|
|
178
|
+
* (删除即使不逐出也自愈:stat 抛错走 miss)。
|
|
179
|
+
*
|
|
180
|
+
* 命中时重放子树首次求值的 cycle/depth/missing issue,保持「每个入口文件
|
|
181
|
+
* 都报告其依赖树装载问题」的诊断口径;method-missing / recursion-truncated
|
|
182
|
+
* 等执行期诊断不随缓存重放——它们属于触发执行的调用方文件,且依赖文件
|
|
183
|
+
* 自身被验证时会独立产出。
|
|
184
|
+
*/
|
|
185
|
+
const absModuleCache = new Map<string, AbsModuleCacheEntry>();
|
|
186
|
+
|
|
187
|
+
export function clearAbsModuleCache(): void {
|
|
188
|
+
absModuleCache.clear();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function evictAbsModuleCacheFiles(paths: string[]): void {
|
|
192
|
+
for (const p of paths) absModuleCache.delete(p);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function moduleLabel(p: string): string {
|
|
196
|
+
const parts = p.split(/[/\\]/);
|
|
197
|
+
return parts[parts.length - 1] || p;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 递归求值相对依赖 + 裸包 harvest,产出入口可用的 modules 表。
|
|
202
|
+
* 循环依赖:先放空表再回填(与 TypeValue 路径 partial 口径一致),
|
|
203
|
+
* 并记录 cycle/depth/missing 供诊断。
|
|
204
|
+
*/
|
|
205
|
+
export function evalAbsModuleGraph(
|
|
206
|
+
entrySource: string,
|
|
207
|
+
entryFile: string,
|
|
208
|
+
opts: AbsGraphOptions = {},
|
|
209
|
+
): AbsModuleGraphResult {
|
|
210
|
+
const load = opts.loadModule ?? defaultAbsLoadModule;
|
|
211
|
+
const maxDepth = opts.maxDepth ?? 16;
|
|
212
|
+
const cache = new Map<string, AbsModuleExports>();
|
|
213
|
+
const loading: string[] = [];
|
|
214
|
+
const issues: AbsModuleLoadIssue[] = [];
|
|
215
|
+
const seenIssue = new Set<string>();
|
|
216
|
+
|
|
217
|
+
const pushIssue = (kind: AbsModuleLoadIssue["kind"], label: string, reason: string) => {
|
|
218
|
+
const key = `${kind}:${label}`;
|
|
219
|
+
if (seenIssue.has(key)) return;
|
|
220
|
+
seenIssue.add(key);
|
|
221
|
+
issues.push({ kind, label, reason });
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function evalDep(absPath: string, spec: string, fromFile: string, depth: number): AbsModuleExports {
|
|
225
|
+
const cycleIndex = loading.indexOf(absPath);
|
|
226
|
+
if (cycleIndex !== -1) {
|
|
227
|
+
const chain = [...loading.slice(cycleIndex), absPath];
|
|
228
|
+
pushIssue(
|
|
229
|
+
"cycle",
|
|
230
|
+
moduleLabel(absPath),
|
|
231
|
+
`Circular module load: ${chain.join(" -> ")} (bindings inside the cycle resolve to their partially evaluated types)`,
|
|
232
|
+
);
|
|
233
|
+
return cache.get(absPath) ?? { named: {} };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 会话缓存命中(指纹严格相等):跳过重读重解析;byPath 也回填完整导出。
|
|
237
|
+
const shared = absModuleCache.get(absPath);
|
|
238
|
+
if (shared) {
|
|
239
|
+
try {
|
|
240
|
+
const st = statSync(absPath);
|
|
241
|
+
if (st.mtimeMs === shared.mtimeMs && st.size === shared.size) {
|
|
242
|
+
for (const iss of shared.issues) pushIssue(iss.kind, iss.label, iss.reason);
|
|
243
|
+
cache.set(absPath, shared.exports);
|
|
244
|
+
return shared.exports;
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
/* 文件已删除 → 指纹失效,走 miss 重新装载 */
|
|
248
|
+
}
|
|
249
|
+
absModuleCache.delete(absPath);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (cache.has(absPath)) return cache.get(absPath)!;
|
|
253
|
+
if (depth > maxDepth) {
|
|
254
|
+
const chain = [...loading, absPath];
|
|
255
|
+
pushIssue(
|
|
256
|
+
"depth",
|
|
257
|
+
moduleLabel(absPath),
|
|
258
|
+
`Module load chain too deep (depth ${chain.length} > ${maxDepth} max): ${chain.join(" -> ")} (loading truncated, deeper modules typed as unknown)`,
|
|
259
|
+
);
|
|
260
|
+
const empty: AbsModuleExports = { named: {} };
|
|
261
|
+
cache.set(absPath, empty);
|
|
262
|
+
return empty;
|
|
263
|
+
}
|
|
264
|
+
cache.set(absPath, { named: {} });
|
|
265
|
+
loading.push(absPath);
|
|
266
|
+
|
|
267
|
+
const source = load(spec, fromFile);
|
|
268
|
+
if (source === undefined) {
|
|
269
|
+
pushIssue(
|
|
270
|
+
"missing",
|
|
271
|
+
spec,
|
|
272
|
+
`Module file not found for '${spec}' (from ${moduleLabel(fromFile)}); tried: ${absPath}`,
|
|
273
|
+
);
|
|
274
|
+
const empty: AbsModuleExports = { named: {} };
|
|
275
|
+
cache.set(absPath, empty);
|
|
276
|
+
loading.pop();
|
|
277
|
+
return empty;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 子树 issue 切片起点:本模块自身(含其依赖)产生的装载问题。
|
|
281
|
+
const issueStart = issues.length;
|
|
282
|
+
const modules = buildModulesForFile(
|
|
283
|
+
source,
|
|
284
|
+
absPath,
|
|
285
|
+
load,
|
|
286
|
+
evalDep,
|
|
287
|
+
depth,
|
|
288
|
+
maxDepth,
|
|
289
|
+
(spec, fromFile, tried) => {
|
|
290
|
+
pushIssue(
|
|
291
|
+
"missing",
|
|
292
|
+
spec,
|
|
293
|
+
`Module file not found for '${spec}' (from ${moduleLabel(fromFile)}); tried: ${tried.join(", ")}`,
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
let exports: AbsModuleExports;
|
|
298
|
+
try {
|
|
299
|
+
const file = parse(source);
|
|
300
|
+
const { env } = evalProgramAbs(source, { file, modules });
|
|
301
|
+
exports = collectAbsExports(file, env, modules);
|
|
302
|
+
} catch {
|
|
303
|
+
exports = { named: {} };
|
|
304
|
+
} finally {
|
|
305
|
+
loading.pop();
|
|
306
|
+
}
|
|
307
|
+
cache.set(absPath, exports);
|
|
308
|
+
|
|
309
|
+
let fingerprint: { mtimeMs: number; size: number } | undefined;
|
|
310
|
+
try {
|
|
311
|
+
const st = statSync(absPath);
|
|
312
|
+
fingerprint = { mtimeMs: st.mtimeMs, size: st.size };
|
|
313
|
+
} catch {
|
|
314
|
+
/* 无指纹(如自定义 loader 的虚拟文件)→ 不入会话缓存 */
|
|
315
|
+
}
|
|
316
|
+
if (fingerprint) {
|
|
317
|
+
absModuleCache.set(absPath, {
|
|
318
|
+
...fingerprint,
|
|
319
|
+
exports,
|
|
320
|
+
issues: issues.slice(issueStart),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return exports;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const modules = buildModulesForFile(
|
|
327
|
+
entrySource,
|
|
328
|
+
entryFile,
|
|
329
|
+
load,
|
|
330
|
+
evalDep,
|
|
331
|
+
0,
|
|
332
|
+
maxDepth,
|
|
333
|
+
(spec, fromFile, tried) => {
|
|
334
|
+
pushIssue(
|
|
335
|
+
"missing",
|
|
336
|
+
spec,
|
|
337
|
+
`Module file not found for '${spec}' (from ${moduleLabel(fromFile)}); tried: ${tried.join(", ")}`,
|
|
338
|
+
);
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
return { modules, byPath: cache, issues };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 便捷:入口求值 + 依赖 Abs 注入 */
|
|
346
|
+
export function evalProgramAbsWithModules(
|
|
347
|
+
source: string,
|
|
348
|
+
entryFile: string,
|
|
349
|
+
opts: AbsGraphOptions & { file?: unknown } = {},
|
|
350
|
+
): { env: AstEnv; last: Abs; phi: Phi } {
|
|
351
|
+
const { modules } = evalAbsModuleGraph(source, entryFile, opts);
|
|
352
|
+
return evalProgramAbs(source, {
|
|
353
|
+
file: opts.file as never,
|
|
354
|
+
modules,
|
|
355
|
+
seedVars: opts.seedVars,
|
|
356
|
+
seedFns: opts.seedFns,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 收集顶层绑定名 → Abs(含相对 import / 裸包 harvest 注入)。
|
|
362
|
+
* 供 bindings / hover 从 Abs 投影,不必走 TypeValue evaluator。
|
|
363
|
+
*/
|
|
364
|
+
export function collectAbsBindingsFromGraph(
|
|
365
|
+
source: string,
|
|
366
|
+
filePath: string,
|
|
367
|
+
opts: AbsGraphOptions = {},
|
|
368
|
+
): Map<string, Abs> {
|
|
369
|
+
const out = new Map<string, Abs>();
|
|
370
|
+
try {
|
|
371
|
+
const { env } = evalProgramAbsWithModules(source, filePath, opts);
|
|
372
|
+
for (const [k, v] of env.vars) {
|
|
373
|
+
out.set(k, v);
|
|
374
|
+
}
|
|
375
|
+
for (const [name, impl] of env.fns) {
|
|
376
|
+
if (!out.has(name)) {
|
|
377
|
+
out.set(
|
|
378
|
+
name,
|
|
379
|
+
absFunction(impl.params, { body: impl.body, async: impl.async, env }),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
/* ignore */
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 整文件 AnalysisResult 会话缓存(service 层,泛型避免与 analyzer 环依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 按 filePath 键控 + source 字符串相等比较:
|
|
5
|
+
* - LSP 同一 buffer 复用 getText() 同一字符串 → SameValueZero O(1)
|
|
6
|
+
* - 编辑后 source 变化 → miss,覆盖旧条目(每文件一份,内存有界)
|
|
7
|
+
* 与 B-path 缓存同生命周期:宿主清 B-path 时一并失效。
|
|
8
|
+
*/
|
|
9
|
+
type Entry = {
|
|
10
|
+
source: string;
|
|
11
|
+
auxKey: string;
|
|
12
|
+
value: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const analysisByFile = new Map<string, Entry>();
|
|
16
|
+
const MAX_ANALYSIS_FILE_CACHE = 64;
|
|
17
|
+
|
|
18
|
+
export function clearAnalysisFileCache(): void {
|
|
19
|
+
analysisByFile.clear();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getAnalysisFileCacheSize(): number {
|
|
23
|
+
return analysisByFile.size;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function analysisCacheGet<T>(filePath: string, source: string, auxKey: string): T | undefined {
|
|
27
|
+
const e = analysisByFile.get(filePath);
|
|
28
|
+
if (!e) return undefined;
|
|
29
|
+
if (e.source !== source || e.auxKey !== auxKey) return undefined;
|
|
30
|
+
// LRU:命中移到队尾
|
|
31
|
+
analysisByFile.delete(filePath);
|
|
32
|
+
analysisByFile.set(filePath, e);
|
|
33
|
+
return e.value as T;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function analysisCacheSet(filePath: string, source: string, auxKey: string, value: unknown): void {
|
|
37
|
+
if (analysisByFile.size >= MAX_ANALYSIS_FILE_CACHE && !analysisByFile.has(filePath)) {
|
|
38
|
+
const oldest = analysisByFile.keys().next().value;
|
|
39
|
+
if (oldest !== undefined) analysisByFile.delete(oldest);
|
|
40
|
+
}
|
|
41
|
+
analysisByFile.set(filePath, { source, auxKey, value });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 依赖变更后:按入口文件逐出 */
|
|
45
|
+
export function evictAnalysisFileCacheForFiles(files: string[]): number {
|
|
46
|
+
let n = 0;
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
if (analysisByFile.delete(f)) n++;
|
|
49
|
+
}
|
|
50
|
+
return n;
|
|
51
|
+
}
|