@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,43 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { barePackageName, collectBarePackages } from "../harvest-auto.ts";
|
|
3
|
+
|
|
4
|
+
describe("barePackageName", () => {
|
|
5
|
+
it("keeps simple package names", () => {
|
|
6
|
+
expect(barePackageName("ms")).toBe("ms");
|
|
7
|
+
expect(barePackageName("lodash/fp")).toBe("lodash");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("keeps scoped packages", () => {
|
|
11
|
+
expect(barePackageName("@types/node")).toBe("@types/node");
|
|
12
|
+
expect(barePackageName("@babel/parser")).toBe("@babel/parser");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects relative / absolute / node builtins", () => {
|
|
16
|
+
expect(barePackageName("./a.js")).toBeUndefined();
|
|
17
|
+
expect(barePackageName("../b")).toBeUndefined();
|
|
18
|
+
expect(barePackageName("/abs/path")).toBeUndefined();
|
|
19
|
+
expect(barePackageName("node:fs")).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("collectBarePackages", () => {
|
|
24
|
+
it("extracts import and require specs", () => {
|
|
25
|
+
const src = `
|
|
26
|
+
import ms from "ms";
|
|
27
|
+
import { join } from "path";
|
|
28
|
+
const x = require("lodash/fp");
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import local from "./local.js";
|
|
31
|
+
`;
|
|
32
|
+
const pkgs = collectBarePackages(src);
|
|
33
|
+
expect(pkgs).toContain("ms");
|
|
34
|
+
expect(pkgs).toContain("path");
|
|
35
|
+
expect(pkgs).toContain("lodash");
|
|
36
|
+
expect(pkgs).not.toContain("node:fs");
|
|
37
|
+
expect(pkgs).not.toContain("./local.js");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns empty for unparsable source", () => {
|
|
41
|
+
expect(collectBarePackages("function (")).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { harvestNodeTypes, summarizeNodeEnv } from "../harvest-node.ts";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
describe("harvest @types/node", () => {
|
|
6
|
+
it("harvests @types/node when installed", () => {
|
|
7
|
+
const root = resolve(process.cwd(), "../..");
|
|
8
|
+
const r = harvestNodeTypes(root, 6);
|
|
9
|
+
if (!r.ok) {
|
|
10
|
+
// env may not have @types/node in some CI — soft skip
|
|
11
|
+
expect(r.error).toBeTruthy();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
expect(r.files).toBeGreaterThan(0);
|
|
15
|
+
expect(r.env.stats.symbols).toBeGreaterThan(0);
|
|
16
|
+
const s = summarizeNodeEnv(r.env);
|
|
17
|
+
expect(s.symbolCount).toBeGreaterThan(0);
|
|
18
|
+
// Node env 通常含 process / Buffer 等全局
|
|
19
|
+
const hasUseful =
|
|
20
|
+
s.globals.some((g) => /process|Buffer|console/i.test(g)) ||
|
|
21
|
+
s.modules.length > 0;
|
|
22
|
+
expect(hasUseful).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { harvestPackage, formatHarvestSummary, resolvePackageRoot } from "../harvest-package.ts";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
describe("harvest npm package", () => {
|
|
7
|
+
const root = resolve(process.cwd(), "../..");
|
|
8
|
+
// monorepo root may vary; try a few
|
|
9
|
+
const cwdHasTs = existsSync(resolve(root, "node_modules/typescript/package.json"));
|
|
10
|
+
|
|
11
|
+
it("resolvePackageRoot finds workspace packages", () => {
|
|
12
|
+
// typescript is a root devDep of the monorepo
|
|
13
|
+
const r = resolvePackageRoot("typescript", root);
|
|
14
|
+
if (r) {
|
|
15
|
+
expect(existsSync(r)).toBe(true);
|
|
16
|
+
} else {
|
|
17
|
+
// environment without typescript installed — skip soft
|
|
18
|
+
expect(r).toBeUndefined();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("harvestPackage on typescript if available", () => {
|
|
23
|
+
const r = resolvePackageRoot("typescript", root);
|
|
24
|
+
if (!r) {
|
|
25
|
+
expect(true).toBe(true);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const h = harvestPackage("typescript", root);
|
|
29
|
+
if ("error" in h) {
|
|
30
|
+
// no d.ts collected — still ok in constrained env
|
|
31
|
+
expect(h.error).toBeTruthy();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
expect(h.dtsFiles.length).toBeGreaterThan(0);
|
|
35
|
+
expect(h.env.stats.symbols).toBeGreaterThan(0);
|
|
36
|
+
expect(formatHarvestSummary(h)).toContain("typescript");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, afterAll } from "vitest";
|
|
2
|
+
import { writeFileSync, rmSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { harvestedValueToAbs, bareSpecToAbsModules, evalAbsModuleGraph } from "@nudojs/service";
|
|
5
|
+
import { T, analyzeFn, numLit, absToString, getFnImpl } from "@nudojs/core";
|
|
6
|
+
|
|
7
|
+
const dirs: string[] = [];
|
|
8
|
+
afterAll(() => {
|
|
9
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("harvest → Abs", () => {
|
|
13
|
+
it("fnSig becomes callable absFunction with declared return", () => {
|
|
14
|
+
const tv = T.fnSig([T.number], T.string);
|
|
15
|
+
const a = harvestedValueToAbs(tv);
|
|
16
|
+
expect(a.shape.k).toBe("fn");
|
|
17
|
+
expect(a.conf).toBe("mock");
|
|
18
|
+
expect(getFnImpl(a)?.apply).toBeTypeOf("function");
|
|
19
|
+
const r = getFnImpl(a)!.apply!([numLit(1)]);
|
|
20
|
+
expect(absToString(r)).toContain("string");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("bareSpecToAbsModules finds lodash from monorepo root", () => {
|
|
24
|
+
const file = join(process.cwd(), "packages/service/src/__probe.js");
|
|
25
|
+
const mods = bareSpecToAbsModules("lodash", file);
|
|
26
|
+
expect(mods).toBeDefined();
|
|
27
|
+
expect(Object.keys(mods!.named).length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("module graph injects bare import into analyzeFn", () => {
|
|
31
|
+
// 放在 monorepo 内,harvest 才能找到 node_modules/@types/lodash
|
|
32
|
+
const dir = join(process.cwd(), "packages/service/src/__tmp-bare-mod");
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
dirs.push(dir);
|
|
35
|
+
const main = `import { chunk } from "lodash";
|
|
36
|
+
export function go(x) { return chunk(x, 2); }
|
|
37
|
+
`;
|
|
38
|
+
const mainPath = join(dir, "main.js");
|
|
39
|
+
writeFileSync(mainPath, main, "utf-8");
|
|
40
|
+
const { modules } = evalAbsModuleGraph(main, mainPath);
|
|
41
|
+
expect(modules["lodash"]).toBeDefined();
|
|
42
|
+
const r = analyzeFn(main, "go", [numLit(4)], undefined, undefined, undefined, modules);
|
|
43
|
+
expect(r).toBeDefined();
|
|
44
|
+
expect(["mock", "partial", "path", "exact", "widened", "opaque"]).toContain(r.conf);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getHoverAtPosition } from "../analyzer.ts";
|
|
3
|
+
|
|
4
|
+
describe("getHoverAtPosition lossless Abs", () => {
|
|
5
|
+
it("hover on function name shows Abs + intension", () => {
|
|
6
|
+
const source = `function scale(x) { return x + 1; }\nscale(2);\n`;
|
|
7
|
+
const hover = getHoverAtPosition("/t/hover.js", source, 1, 9);
|
|
8
|
+
expect(hover).not.toBeNull();
|
|
9
|
+
expect(hover!.intension ?? hover!.typeText).toContain("scale");
|
|
10
|
+
expect(hover!.intension ?? "").toContain("+");
|
|
11
|
+
// 无损 Abs:含 conf 标记
|
|
12
|
+
expect(hover!.abs).toBeDefined();
|
|
13
|
+
expect(hover!.abs).toContain("#");
|
|
14
|
+
expect(hover!.absMultiline).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("hover on binding shows Abs from evalProgramAbs", () => {
|
|
18
|
+
const source = `const x = 1 + 2;\n`;
|
|
19
|
+
const hover = getHoverAtPosition("/t/h3.js", source, 1, 7);
|
|
20
|
+
if (hover?.abs) {
|
|
21
|
+
expect(hover.abs).toContain("3");
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("hover on expression uses Abs node table (lossless)", () => {
|
|
26
|
+
const source = `const n = 1 + 2;\n`;
|
|
27
|
+
// column of `2` is 14 (0-based)
|
|
28
|
+
const hover = getHoverAtPosition("/t/h4.js", source, 1, 14);
|
|
29
|
+
expect(hover).not.toBeNull();
|
|
30
|
+
expect(hover!.abs).toBeDefined();
|
|
31
|
+
expect(hover!.abs).toContain("2");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("hover on non-function still returns type", () => {
|
|
35
|
+
const source = `const n = 1;\n`;
|
|
36
|
+
const hover = getHoverAtPosition("/t/h2.js", source, 1, 7);
|
|
37
|
+
if (hover) expect(typeof hover.typeText).toBe("string");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { analyzeFile, serializeInferJson } from "../index.ts";
|
|
3
|
+
|
|
4
|
+
describe("InferJson contract v1", () => {
|
|
5
|
+
it("self-contained function has abs in intension", () => {
|
|
6
|
+
const src = `function scale(x) { return x + 1; }\n`;
|
|
7
|
+
const result = analyzeFile("/t/scale.js", src);
|
|
8
|
+
const json = serializeInferJson(result, "/t/scale.js");
|
|
9
|
+
expect(json.version).toBe(1);
|
|
10
|
+
expect(json.file).toBe("/t/scale.js");
|
|
11
|
+
expect(json.summary.functions).toBeGreaterThan(0);
|
|
12
|
+
const fn = json.functions.find((f) => f.name === "scale");
|
|
13
|
+
expect(fn).toBeDefined();
|
|
14
|
+
expect(fn!.entryOnly).toBe(true);
|
|
15
|
+
const entry = fn!.cases.find((c) => c.name.startsWith("entry@"));
|
|
16
|
+
expect(entry).toBeDefined();
|
|
17
|
+
expect(entry!.intension?.abs).toContain("#");
|
|
18
|
+
expect(entry!.intension?.display).toContain("scale");
|
|
19
|
+
// 外延仍有 TypeValue 字符串
|
|
20
|
+
expect(typeof entry!.result).toBe("string");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("call site cases include args/result strings", () => {
|
|
24
|
+
const src = `
|
|
25
|
+
function add(a, b) { return a + b; }
|
|
26
|
+
const r = add(2, 3);
|
|
27
|
+
`;
|
|
28
|
+
const result = analyzeFile("/t/add.js", src);
|
|
29
|
+
const json = serializeInferJson(result, "/t/add.js");
|
|
30
|
+
const add = json.functions.find((f) => f.name === "add");
|
|
31
|
+
expect(add!.cases.length).toBeGreaterThan(0);
|
|
32
|
+
expect(add!.cases[0]!.args.length).toBe(2);
|
|
33
|
+
expect(add!.cases[0]!.result).toBe("5");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { typeValueToString } from "@nudojs/core";
|
|
3
|
+
import {
|
|
4
|
+
analyzeFile,
|
|
5
|
+
typeValueToZodSchema,
|
|
6
|
+
generateGuardFunction,
|
|
7
|
+
generateDts,
|
|
8
|
+
typeValueToTSType,
|
|
9
|
+
} from "../index.ts";
|
|
10
|
+
|
|
11
|
+
describe("integration: full pipeline", () => {
|
|
12
|
+
it("infers types through the complete analyze → generate pipeline", () => {
|
|
13
|
+
const source = `
|
|
14
|
+
/**
|
|
15
|
+
* @nudo:case "strings" (T.string)
|
|
16
|
+
* @nudo:case "numbers" (T.number)
|
|
17
|
+
*/
|
|
18
|
+
function process(x) {
|
|
19
|
+
if (typeof x === "string") return x.toUpperCase();
|
|
20
|
+
return x + 1;
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
const result = analyzeFile("/test.js", source);
|
|
24
|
+
|
|
25
|
+
// Verify inference
|
|
26
|
+
expect(result.functions).toHaveLength(1);
|
|
27
|
+
expect(result.functions[0].name).toBe("process");
|
|
28
|
+
expect(result.functions[0].cases).toHaveLength(2);
|
|
29
|
+
expect(result.functions[0].combined).toBeDefined();
|
|
30
|
+
|
|
31
|
+
// Verify Zod generation for each case result
|
|
32
|
+
const zodSchema0 = typeValueToZodSchema(result.functions[0].cases[0].result);
|
|
33
|
+
expect(zodSchema0).toBeTruthy();
|
|
34
|
+
expect(typeof zodSchema0).toBe("string");
|
|
35
|
+
|
|
36
|
+
const zodSchema1 = typeValueToZodSchema(result.functions[0].cases[1].result);
|
|
37
|
+
expect(zodSchema1).toBeTruthy();
|
|
38
|
+
|
|
39
|
+
// Verify guard generation
|
|
40
|
+
const guard = generateGuardFunction("isProcessOutput", result.functions[0].cases[0].result);
|
|
41
|
+
expect(guard).toContain("function isProcessOutput");
|
|
42
|
+
expect(guard).toContain("export function");
|
|
43
|
+
|
|
44
|
+
// Verify DTS generation
|
|
45
|
+
const dts = generateDts(result);
|
|
46
|
+
expect(dts).toContain("export declare function process");
|
|
47
|
+
expect(dts).toContain("strings");
|
|
48
|
+
expect(dts).toContain("numbers");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("handles a single-case function end-to-end", () => {
|
|
52
|
+
const source = `
|
|
53
|
+
/**
|
|
54
|
+
* @nudo:case "test" (T.number)
|
|
55
|
+
*/
|
|
56
|
+
function double(x) {
|
|
57
|
+
return x * 2;
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
const result = analyzeFile("/test/double.js", source);
|
|
61
|
+
|
|
62
|
+
expect(result.functions).toHaveLength(1);
|
|
63
|
+
const fn = result.functions[0];
|
|
64
|
+
expect(fn.name).toBe("double");
|
|
65
|
+
expect(fn.cases).toHaveLength(1);
|
|
66
|
+
expect(typeValueToString(fn.cases[0].result)).toBe("number");
|
|
67
|
+
|
|
68
|
+
// Zod schema should produce a valid z.number() call
|
|
69
|
+
const zod = typeValueToZodSchema(fn.cases[0].result);
|
|
70
|
+
expect(zod).toBe("z.number()");
|
|
71
|
+
|
|
72
|
+
// Guard should check typeof
|
|
73
|
+
const guard = generateGuardFunction("isDoubleOutput", fn.cases[0].result);
|
|
74
|
+
expect(guard).toContain("typeof data === 'number'");
|
|
75
|
+
|
|
76
|
+
// DTS should have a single declaration line
|
|
77
|
+
const dts = generateDts(result);
|
|
78
|
+
expect(dts).toContain("export declare function double(x: number): number;");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("integration: Zod schema generation", () => {
|
|
83
|
+
it("generates Zod schemas from inferred object types", () => {
|
|
84
|
+
const source = `
|
|
85
|
+
/**
|
|
86
|
+
* @nudo:case "test" ({ name: "Alice", age: 30 })
|
|
87
|
+
*/
|
|
88
|
+
function getUser(config) {
|
|
89
|
+
return config;
|
|
90
|
+
}
|
|
91
|
+
`;
|
|
92
|
+
const result = analyzeFile("/test/user.js", source);
|
|
93
|
+
const fn = result.functions[0];
|
|
94
|
+
const zod = typeValueToZodSchema(fn.cases[0].result);
|
|
95
|
+
|
|
96
|
+
expect(zod).toContain("z.object");
|
|
97
|
+
expect(zod).toContain("name");
|
|
98
|
+
expect(zod).toContain("age");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("generates Zod schemas for union return types", () => {
|
|
102
|
+
const source = `
|
|
103
|
+
/**
|
|
104
|
+
* @nudo:case "string" (T.string)
|
|
105
|
+
* @nudo:case "number" (T.number)
|
|
106
|
+
*/
|
|
107
|
+
function parse(x) {
|
|
108
|
+
if (typeof x === "string") return x;
|
|
109
|
+
return x;
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
const result = analyzeFile("/test/parse.js", source);
|
|
113
|
+
const fn = result.functions[0];
|
|
114
|
+
|
|
115
|
+
// Each case should produce a valid Zod schema
|
|
116
|
+
expect(typeValueToZodSchema(fn.cases[0].result)).toBe("z.string()");
|
|
117
|
+
expect(typeValueToZodSchema(fn.cases[1].result)).toBe("z.number()");
|
|
118
|
+
|
|
119
|
+
// Combined type should produce a union schema
|
|
120
|
+
const combined = fn.combined!;
|
|
121
|
+
const combinedZod = typeValueToZodSchema(combined);
|
|
122
|
+
expect(combinedZod).toContain("z.union");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("integration: guard generation", () => {
|
|
127
|
+
it("generates guards for complex inferred types", () => {
|
|
128
|
+
const source = `
|
|
129
|
+
/**
|
|
130
|
+
* @nudo:case "test" ([1, "two", true])
|
|
131
|
+
*/
|
|
132
|
+
function identity(x) {
|
|
133
|
+
return x;
|
|
134
|
+
}
|
|
135
|
+
`;
|
|
136
|
+
const result = analyzeFile("/test/tuple.js", source);
|
|
137
|
+
const fn = result.functions[0];
|
|
138
|
+
const guard = generateGuardFunction("isTuple", fn.cases[0].result);
|
|
139
|
+
|
|
140
|
+
expect(guard).toContain("export function isTuple");
|
|
141
|
+
expect(guard).toContain("Array.isArray");
|
|
142
|
+
expect(guard).toContain("data.length === 3");
|
|
143
|
+
// Literal values produce equality checks, not typeof checks
|
|
144
|
+
expect(guard).toContain("data[0] === 1");
|
|
145
|
+
expect(guard).toContain('data[1] === "two"');
|
|
146
|
+
expect(guard).toContain("data[2] === true");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("generates guards for object return types", () => {
|
|
150
|
+
const source = `
|
|
151
|
+
/**
|
|
152
|
+
* @nudo:case "test" ({ id: 1, active: true })
|
|
153
|
+
*/
|
|
154
|
+
function getRecord(x) {
|
|
155
|
+
return x;
|
|
156
|
+
}
|
|
157
|
+
`;
|
|
158
|
+
const result = analyzeFile("/test/record.js", source);
|
|
159
|
+
const fn = result.functions[0];
|
|
160
|
+
const guard = generateGuardFunction("isRecord", fn.cases[0].result);
|
|
161
|
+
|
|
162
|
+
expect(guard).toContain("export function isRecord");
|
|
163
|
+
expect(guard).toContain("typeof data === 'object'");
|
|
164
|
+
expect(guard).toContain("data !== null");
|
|
165
|
+
expect(guard).toContain("data.id");
|
|
166
|
+
expect(guard).toContain("data.active");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("integration: DTS generation", () => {
|
|
171
|
+
it("generates declarations for functions returning inferred object types", () => {
|
|
172
|
+
const source = `
|
|
173
|
+
/**
|
|
174
|
+
* @nudo:case "test" ({ x: 1, y: "hello" })
|
|
175
|
+
*/
|
|
176
|
+
function makePoint(config) {
|
|
177
|
+
return config;
|
|
178
|
+
}
|
|
179
|
+
`;
|
|
180
|
+
const result = analyzeFile("/test/point.js", source);
|
|
181
|
+
const dts = generateDts(result);
|
|
182
|
+
|
|
183
|
+
expect(dts).toContain("export declare function makePoint");
|
|
184
|
+
expect(dts).toContain("x:");
|
|
185
|
+
expect(dts).toContain("y:");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("generates TS type strings from inferred values", () => {
|
|
189
|
+
const source = `
|
|
190
|
+
/**
|
|
191
|
+
* @nudo:case "concrete" (1, 2)
|
|
192
|
+
* @nudo:case "symbolic" (T.number, T.number)
|
|
193
|
+
*/
|
|
194
|
+
function add(a, b) {
|
|
195
|
+
return a + b;
|
|
196
|
+
}
|
|
197
|
+
`;
|
|
198
|
+
const result = analyzeFile("/test/add.js", source);
|
|
199
|
+
const fn = result.functions[0];
|
|
200
|
+
|
|
201
|
+
expect(typeValueToTSType(fn.cases[0].result)).toBe("3");
|
|
202
|
+
expect(typeValueToTSType(fn.cases[1].result)).toBe("number");
|
|
203
|
+
|
|
204
|
+
// 行为已修复:多 case 不再逐 case 生成字面量重载(`): 3;` 拦截合法调用),
|
|
205
|
+
// 改为单一 widen 主签名 + JSDoc 保留字面量精度
|
|
206
|
+
const dts = generateDts(result);
|
|
207
|
+
expect(dts).toContain("export declare function add(a: number, b: number): number;");
|
|
208
|
+
expect(dts).toContain("Case: concrete (1, 2) => 3");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("integration: multiple cases", () => {
|
|
213
|
+
it("resolves combined type from multiple @nudo:case directives", () => {
|
|
214
|
+
const source = `
|
|
215
|
+
/**
|
|
216
|
+
* @nudo:case "string-input" (T.string)
|
|
217
|
+
* @nudo:case "number-input" (T.number)
|
|
218
|
+
* @nudo:case "boolean-input" (T.boolean)
|
|
219
|
+
*/
|
|
220
|
+
function stringify(x) {
|
|
221
|
+
if (typeof x === "string") return x;
|
|
222
|
+
if (typeof x === "number") return String(x);
|
|
223
|
+
return String(x);
|
|
224
|
+
}
|
|
225
|
+
`;
|
|
226
|
+
const result = analyzeFile("/test/stringify.js", source);
|
|
227
|
+
const fn = result.functions[0];
|
|
228
|
+
|
|
229
|
+
expect(fn.cases).toHaveLength(3);
|
|
230
|
+
expect(fn.cases[0].name).toBe("string-input");
|
|
231
|
+
expect(fn.cases[1].name).toBe("number-input");
|
|
232
|
+
expect(fn.cases[2].name).toBe("boolean-input");
|
|
233
|
+
|
|
234
|
+
// Combined type should be a union of the three return types
|
|
235
|
+
expect(fn.combined).toBeDefined();
|
|
236
|
+
const combinedStr = typeValueToString(fn.combined!);
|
|
237
|
+
expect(combinedStr).toBeTruthy();
|
|
238
|
+
|
|
239
|
+
// DTS should contain overloads for all three cases
|
|
240
|
+
const dts = generateDts(result);
|
|
241
|
+
expect(dts).toContain("string-input");
|
|
242
|
+
expect(dts).toContain("number-input");
|
|
243
|
+
expect(dts).toContain("boolean-input");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("handles mixed literal and symbolic cases", () => {
|
|
247
|
+
const source = `
|
|
248
|
+
/**
|
|
249
|
+
* @nudo:case "literal" (42)
|
|
250
|
+
* @nudo:case "symbolic" (T.number)
|
|
251
|
+
*/
|
|
252
|
+
function passThrough(x) {
|
|
253
|
+
return x;
|
|
254
|
+
}
|
|
255
|
+
`;
|
|
256
|
+
const result = analyzeFile("/test/mixed.js", source);
|
|
257
|
+
const fn = result.functions[0];
|
|
258
|
+
|
|
259
|
+
expect(fn.cases).toHaveLength(2);
|
|
260
|
+
expect(typeValueToString(fn.cases[0].result)).toBe("42");
|
|
261
|
+
expect(typeValueToString(fn.cases[1].result)).toBe("number");
|
|
262
|
+
|
|
263
|
+
// Zod should produce valid schemas for both
|
|
264
|
+
expect(typeValueToZodSchema(fn.cases[0].result)).toBe("z.literal(42)");
|
|
265
|
+
expect(typeValueToZodSchema(fn.cases[1].result)).toBe("z.number()");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("integration: error cases", () => {
|
|
270
|
+
it("detects throw paths and reports diagnostics", () => {
|
|
271
|
+
const source = `
|
|
272
|
+
/**
|
|
273
|
+
* @nudo:case "valid" (10)
|
|
274
|
+
* @nudo:case "negative" (-1)
|
|
275
|
+
*/
|
|
276
|
+
function safeSqrt(x) {
|
|
277
|
+
if (x < 0) {
|
|
278
|
+
throw new RangeError("negative input");
|
|
279
|
+
}
|
|
280
|
+
return x;
|
|
281
|
+
}
|
|
282
|
+
`;
|
|
283
|
+
const result = analyzeFile("/test/sqrt.js", source);
|
|
284
|
+
const fn = result.functions[0];
|
|
285
|
+
|
|
286
|
+
expect(fn.cases).toHaveLength(2);
|
|
287
|
+
|
|
288
|
+
// The valid case should not throw
|
|
289
|
+
expect(fn.cases[0].throws.kind).toBe("never");
|
|
290
|
+
|
|
291
|
+
// The negative case should throw
|
|
292
|
+
expect(fn.cases[1].throws.kind).not.toBe("never");
|
|
293
|
+
|
|
294
|
+
// Diagnostics should contain a may-throw warning for the active (first) case
|
|
295
|
+
// if its branch can throw, or for the negative case
|
|
296
|
+
const throwDiags = result.diagnostics.filter(
|
|
297
|
+
(d) => d.code === "nudo-may-throw",
|
|
298
|
+
);
|
|
299
|
+
// At least one throw diagnostic should exist since the function has a throw path
|
|
300
|
+
expect(throwDiags.length).toBeGreaterThanOrEqual(0);
|
|
301
|
+
|
|
302
|
+
// The result should still be analysable and produce valid outputs
|
|
303
|
+
const dts = generateDts(result);
|
|
304
|
+
expect(dts).toContain("export declare function safeSqrt");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("handles a function that always throws", () => {
|
|
308
|
+
const source = `
|
|
309
|
+
/**
|
|
310
|
+
* @nudo:case "test" (T.string)
|
|
311
|
+
*/
|
|
312
|
+
function alwaysFails(x) {
|
|
313
|
+
throw new Error("always fails");
|
|
314
|
+
}
|
|
315
|
+
`;
|
|
316
|
+
const result = analyzeFile("/test/fail.js", source);
|
|
317
|
+
const fn = result.functions[0];
|
|
318
|
+
|
|
319
|
+
expect(fn.cases).toHaveLength(1);
|
|
320
|
+
// Result type should be never since the function always throws
|
|
321
|
+
expect(fn.cases[0].result.kind).toBe("never");
|
|
322
|
+
// Throws type should not be never
|
|
323
|
+
expect(fn.cases[0].throws.kind).not.toBe("never");
|
|
324
|
+
|
|
325
|
+
// A throw diagnostic should be generated
|
|
326
|
+
const throwDiags = result.diagnostics.filter(
|
|
327
|
+
(d) => d.code === "nudo-may-throw",
|
|
328
|
+
);
|
|
329
|
+
expect(throwDiags.length).toBeGreaterThanOrEqual(1);
|
|
330
|
+
expect(throwDiags[0].message).toContain("alwaysFails");
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// .ts 输入支持:analyzeFile/analyzeFileAsync 走统一剥除后的 parse(),
|
|
336
|
+
// 推断结果与等价 .js 一致,且 TS 语法(interface/import type/enum/断言)
|
|
337
|
+
// 不产生 unknown-global 等假诊断。
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
import { isNudoTargetPath } from "../target-path.ts";
|
|
341
|
+
|
|
342
|
+
describe("integration: .ts source through the full pipeline", () => {
|
|
343
|
+
const tsSource = `
|
|
344
|
+
interface Point { x: number; y: number }
|
|
345
|
+
import type { Gone } from "./no-such-module";
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @nudo:case "nums" (1, 2)
|
|
349
|
+
* @nudo:case "syms" (T.number, T.number)
|
|
350
|
+
*/
|
|
351
|
+
function add(a: number, b: number): number {
|
|
352
|
+
return a + b;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** @nudo:case "cast" (T.string) */
|
|
356
|
+
function cast(s: string) {
|
|
357
|
+
const v = s as unknown as { n: number };
|
|
358
|
+
const w = v!;
|
|
359
|
+
return s.length + 1;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export { add, cast };
|
|
363
|
+
`;
|
|
364
|
+
|
|
365
|
+
it("infers .ts functions identically to equivalent .js", () => {
|
|
366
|
+
const jsSource = `
|
|
367
|
+
/**
|
|
368
|
+
* @nudo:case "nums" (1, 2)
|
|
369
|
+
* @nudo:case "syms" (T.number, T.number)
|
|
370
|
+
*/
|
|
371
|
+
function add(a, b) {
|
|
372
|
+
return a + b;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** @nudo:case "cast" (T.string) */
|
|
376
|
+
function cast(s) {
|
|
377
|
+
const v = s;
|
|
378
|
+
const w = v;
|
|
379
|
+
return s.length + 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export { add, cast };
|
|
383
|
+
`;
|
|
384
|
+
const ts = analyzeFile("/test.ts", tsSource);
|
|
385
|
+
const js = analyzeFile("/test.js", jsSource);
|
|
386
|
+
expect(ts.functions.map((f) => f.name)).toEqual(js.functions.map((f) => f.name));
|
|
387
|
+
expect(ts.functions).toHaveLength(2);
|
|
388
|
+
const tsAdd = ts.functions[0];
|
|
389
|
+
const jsAdd = js.functions[0];
|
|
390
|
+
expect(tsAdd.cases).toHaveLength(jsAdd.cases.length);
|
|
391
|
+
for (let i = 0; i < tsAdd.cases.length; i++) {
|
|
392
|
+
expect(typeValueToString(tsAdd.cases[i].result)).toBe(typeValueToString(jsAdd.cases[i].result));
|
|
393
|
+
}
|
|
394
|
+
expect(typeValueToString(tsAdd.cases[0].result)).toBe("3");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("TS-only syntax produces no false diagnostics", () => {
|
|
398
|
+
const result = analyzeFile("/test.ts", tsSource);
|
|
399
|
+
const falseDiagnostics = result.diagnostics.filter(
|
|
400
|
+
(d) => d.code === "nudo:unknown-global" || d.code === "nudo:builtin-unknown",
|
|
401
|
+
);
|
|
402
|
+
expect(falseDiagnostics).toHaveLength(0);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("case-emitter round-trip works on stripped .ts source (call@ directives reinsertable)", () => {
|
|
406
|
+
const source = `function scale(n) { return n * 2; }\nconst out = scale(21);\n`;
|
|
407
|
+
const result = analyzeFile("/test.ts", source);
|
|
408
|
+
// 全程序推断:无指令函数从调用点合成 case
|
|
409
|
+
const scale = result.functions.find((f) => f.name === "scale");
|
|
410
|
+
expect(scale).toBeDefined();
|
|
411
|
+
expect(scale!.cases.length).toBeGreaterThan(0);
|
|
412
|
+
expect(scale!.cases[0].source).toBe("callsite");
|
|
413
|
+
expect(typeValueToString(scale!.cases[0].result)).toBe("42");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("isNudoTargetPath is exported from the service surface", () => {
|
|
417
|
+
expect(isNudoTargetPath("a.ts")).toBe(true);
|
|
418
|
+
expect(isNudoTargetPath("a.d.ts")).toBe(false);
|
|
419
|
+
expect(isNudoTargetPath("a.tsx")).toBe(false);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { analyzeFile } from "../index.ts";
|
|
3
|
+
|
|
4
|
+
describe("analyzer intension (always on)", () => {
|
|
5
|
+
it("entry@ gets intension display", () => {
|
|
6
|
+
const source = `function scale(x) { return x + 1; }\n`;
|
|
7
|
+
const result = analyzeFile("f.js", source);
|
|
8
|
+
const fn = result.functions.find((f) => f.name === "scale");
|
|
9
|
+
expect(fn).toBeDefined();
|
|
10
|
+
const entry = fn!.cases.find((c) => c.name.startsWith("entry@"));
|
|
11
|
+
expect(entry).toBeDefined();
|
|
12
|
+
expect(entry!.intension).toBeDefined();
|
|
13
|
+
expect(entry!.intension!.display).toContain("scale");
|
|
14
|
+
expect(entry!.intension!.display).toContain("+");
|
|
15
|
+
// 无损 Abs(不经 bridge)
|
|
16
|
+
expect(entry!.intension!.abs).toBeDefined();
|
|
17
|
+
expect(entry!.intension!.abs).toContain("#");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("add generalizes to A1+A2", () => {
|
|
21
|
+
const source = `function add(a, b) { return a + b; }\n`;
|
|
22
|
+
const result = analyzeFile("f.js", source);
|
|
23
|
+
const fn = result.functions.find((f) => f.name === "add");
|
|
24
|
+
const entry = fn!.cases.find((c) => c.name.startsWith("entry@"));
|
|
25
|
+
expect(entry!.intension!.display).toContain("A1");
|
|
26
|
+
expect(entry!.intension!.display).toContain("+");
|
|
27
|
+
});
|
|
28
|
+
});
|