@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +27 -0
  3. package/dist/index.d.ts +638 -5
  4. package/dist/index.js +4439 -208
  5. package/package.json +17 -4
  6. package/src/__tests__/abs-bindings.test.ts +53 -0
  7. package/src/__tests__/abs-call-records.test.ts +33 -0
  8. package/src/__tests__/abs-calls.test.ts +59 -0
  9. package/src/__tests__/abs-module-cache.test.ts +121 -0
  10. package/src/__tests__/abs-modules-graph.test.ts +100 -0
  11. package/src/__tests__/abs-typevalue-diff.test.ts +111 -0
  12. package/src/__tests__/analyzer.test.ts +745 -2
  13. package/src/__tests__/as-replace.test.ts +165 -0
  14. package/src/__tests__/bpath-arg-provenance.test.ts +104 -0
  15. package/src/__tests__/bpath-as.test.ts +54 -0
  16. package/src/__tests__/bpath-cache-key.test.ts +33 -0
  17. package/src/__tests__/bpath-calls.test.ts +55 -0
  18. package/src/__tests__/bpath-diagnostics.test.ts +78 -0
  19. package/src/__tests__/bpath-env-modules.test.ts +30 -0
  20. package/src/__tests__/bpath-env.test.ts +80 -0
  21. package/src/__tests__/bpath-hosted-skip.test.ts +71 -0
  22. package/src/__tests__/bpath-import-default-ns.test.ts +106 -0
  23. package/src/__tests__/bpath-method-dedupe.test.ts +65 -0
  24. package/src/__tests__/bpath-method-diag.test.ts +45 -0
  25. package/src/__tests__/bpath-module-diags.test.ts +101 -0
  26. package/src/__tests__/bpath-path-env.test.ts +46 -0
  27. package/src/__tests__/bpath-reexport.test.ts +131 -0
  28. package/src/__tests__/bpath-replace.test.ts +52 -0
  29. package/src/__tests__/bpath-require.test.ts +66 -0
  30. package/src/__tests__/bpath-run.test.ts +109 -0
  31. package/src/__tests__/case-emitter.test.ts +636 -0
  32. package/src/__tests__/cjs-require.test.ts +54 -0
  33. package/src/__tests__/diagnostics.test.ts +60 -0
  34. package/src/__tests__/dts-generator.test.ts +290 -6
  35. package/src/__tests__/fn-cache-evict.test.ts +56 -0
  36. package/src/__tests__/fn-sig-impl.test.ts +247 -0
  37. package/src/__tests__/guard-generator.test.ts +29 -0
  38. package/src/__tests__/harvest-auto.test.ts +43 -0
  39. package/src/__tests__/harvest-node.test.ts +24 -0
  40. package/src/__tests__/harvest-package.test.ts +38 -0
  41. package/src/__tests__/harvest-to-abs.test.ts +46 -0
  42. package/src/__tests__/hover-intension.test.ts +39 -0
  43. package/src/__tests__/infer-json.test.ts +35 -0
  44. package/src/__tests__/integration.test.ts +421 -0
  45. package/src/__tests__/m3-intension.test.ts +28 -0
  46. package/src/__tests__/mock-seed-fingerprint.test.ts +64 -0
  47. package/src/__tests__/schema-generator.test.ts +38 -0
  48. package/src/__tests__/session-cache-evict.test.ts +90 -0
  49. package/src/__tests__/static-imports.test.ts +44 -0
  50. package/src/abs-modules-graph.ts +387 -0
  51. package/src/analysis-file-cache.ts +51 -0
  52. package/src/analyzer.ts +2569 -94
  53. package/src/bpath-diagnostics.ts +283 -0
  54. package/src/bpath-run.ts +409 -0
  55. package/src/case-emitter.ts +477 -0
  56. package/src/dts-generator.ts +220 -24
  57. package/src/env-to-abs.ts +57 -0
  58. package/src/fn-analysis-cache.ts +119 -0
  59. package/src/guard-generator.ts +57 -0
  60. package/src/harvest-auto.ts +86 -0
  61. package/src/harvest-node.ts +48 -0
  62. package/src/harvest-package.ts +204 -0
  63. package/src/harvest-to-abs.ts +129 -0
  64. package/src/index.ts +128 -0
  65. package/src/infer-json.ts +137 -0
  66. package/src/load-module.ts +34 -0
  67. package/src/mock-abs.ts +275 -0
  68. package/src/schema-generator.ts +43 -0
  69. package/src/semantic-tokens.ts +216 -0
  70. package/src/session-cache.ts +50 -0
  71. package/src/static-imports.ts +241 -0
  72. package/src/target-path.ts +17 -0
@@ -0,0 +1,636 @@
1
+ // case-emitter.test.ts — packages/service/src/case-emitter.ts 的测试。
2
+ // 该模块为新建(调用点固化:序列化合成 case、剥离/插入 @nudo:case 指令、
3
+ // unified diff),此前无既有测试归属文件,故独立成文件。
4
+ import { describe, it, expect } from "vitest";
5
+ import { T, createEnvironment, typeValueEquals, type TypeValue } from "@nudojs/core";
6
+ import { parse, extractDirectives, parseTypeValueExpr, type CaseDirective } from "@nudojs/parser";
7
+ import {
8
+ serializeCaseArg,
9
+ buildCaseDirective,
10
+ stripGeneratedCaseDirectives,
11
+ insertGeneratedCaseDirectives,
12
+ unifiedDiff,
13
+ } from "../case-emitter.ts";
14
+ import { analyzeFile, type AnalysisResult, type FunctionAnalysis } from "../analyzer.ts";
15
+
16
+ /**
17
+ * 结构等价比较:core 的 typeValueEquals 对 object/tuple/array 是引用比较
18
+ * (object 带唯一 symbol id),序列化往返需要按结构比较。
19
+ */
20
+ function structurallyEqual(a: TypeValue, b: TypeValue): boolean {
21
+ if (a.kind !== b.kind) return false;
22
+ switch (a.kind) {
23
+ case "literal": {
24
+ const bb = b as typeof a;
25
+ return a.value === bb.value;
26
+ }
27
+ case "primitive": {
28
+ const bb = b as typeof a;
29
+ return a.type === bb.type;
30
+ }
31
+ case "array": {
32
+ const bb = b as typeof a;
33
+ return structurallyEqual(a.element, bb.element);
34
+ }
35
+ case "tuple": {
36
+ const bb = b as typeof a;
37
+ return (
38
+ a.elements.length === bb.elements.length &&
39
+ a.elements.every((e, i) => structurallyEqual(e, bb.elements[i]))
40
+ );
41
+ }
42
+ case "object": {
43
+ const bb = b as typeof a;
44
+ const ka = Object.keys(a.properties);
45
+ const kb = Object.keys(bb.properties);
46
+ return (
47
+ ka.length === kb.length &&
48
+ ka.every((k) => k in bb.properties && structurallyEqual(a.properties[k], bb.properties[k]))
49
+ );
50
+ }
51
+ default:
52
+ // union/never/unknown/refined/promise/instance core 已做结构比较
53
+ return typeValueEquals(a, b);
54
+ }
55
+ }
56
+
57
+ function dummyFn(): TypeValue {
58
+ return T.fn(["x"], { type: "BlockStatement", body: [] } as any, createEnvironment());
59
+ }
60
+
61
+ function makeFn(
62
+ name: string,
63
+ line: number,
64
+ cases: Array<{ name: string; args: TypeValue[]; source?: "callsite" }>,
65
+ flags: { skipped?: boolean; noDeclaration?: boolean; entryOnly?: boolean; column?: number } = {},
66
+ ): FunctionAnalysis {
67
+ return {
68
+ name,
69
+ loc: { start: { line, column: flags.column ?? 0 }, end: { line, column: 0 } },
70
+ paramNames: [],
71
+ cases: cases.map((c) => ({
72
+ name: c.name,
73
+ args: c.args,
74
+ result: T.unknown,
75
+ throws: T.never,
76
+ source: c.source,
77
+ })),
78
+ skipped: flags.skipped,
79
+ noDeclaration: flags.noDeclaration,
80
+ entryOnly: flags.entryOnly,
81
+ };
82
+ }
83
+
84
+ function makeAnalysis(functions: FunctionAnalysis[]): AnalysisResult {
85
+ return { functions, diagnostics: [], bindings: new Map(), nodeTypeMap: new Map(), caseHints: [] };
86
+ }
87
+
88
+ describe("serializeCaseArg", () => {
89
+ const matrix: Array<{ label: string; value: TypeValue }> = [
90
+ { label: "number", value: T.number },
91
+ { label: "string", value: T.string },
92
+ { label: "boolean", value: T.boolean },
93
+ { label: "unknown", value: T.unknown },
94
+ { label: "never", value: T.never },
95
+ { label: "null literal", value: T.literal(null) },
96
+ { label: "undefined literal", value: T.literal(undefined) },
97
+ { label: "true", value: T.literal(true) },
98
+ { label: "false", value: T.literal(false) },
99
+ { label: "integer literal", value: T.literal(0) },
100
+ { label: "negative float literal", value: T.literal(-1.5) },
101
+ { label: "plain string", value: T.literal("hello") },
102
+ { label: "string with double quote", value: T.literal('a"b') },
103
+ { label: "string with single quote", value: T.literal("a'b") },
104
+ { label: "string with backslash", value: T.literal("a\\b") },
105
+ { label: "string with colon (value position is safe)", value: T.literal("2024-01-01T10:00:00") },
106
+ { label: "string with arrow", value: T.literal("a=>b") },
107
+ { label: "union", value: T.union(T.number, T.string) },
108
+ { label: "union of literals", value: T.union(T.literal(1), T.literal("x")) },
109
+ { label: "array", value: T.array(T.number) },
110
+ { label: "tuple", value: T.tuple([T.literal(1), T.literal("x")]) },
111
+ { label: "empty tuple", value: T.tuple([]) },
112
+ { label: "object", value: T.object({ a: T.literal(1), b: T.string }) },
113
+ { label: "empty object", value: T.object({}) },
114
+ { label: "non-identifier object key", value: T.object({ "a-b": T.number }) },
115
+ { label: "space object key", value: T.object({ "a b": T.number }) },
116
+ {
117
+ label: "nested containers",
118
+ value: T.object({
119
+ list: T.array(T.union(T.number, T.literal(null))),
120
+ pair: T.tuple([T.string, T.boolean]),
121
+ }),
122
+ },
123
+ ];
124
+
125
+ it("serializes every shape in the grammar matrix", () => {
126
+ expect(serializeCaseArg(T.number)).toBe("T.number");
127
+ expect(serializeCaseArg(T.literal(1))).toBe("1");
128
+ expect(serializeCaseArg(T.literal("hi"))).toBe('"hi"');
129
+ expect(serializeCaseArg(T.literal('a"b'))).toBe("'a\"b'");
130
+ expect(serializeCaseArg(T.literal("a'b"))).toBe('"a\'b"');
131
+ expect(serializeCaseArg(T.array(T.number))).toBe("T.array(T.number)");
132
+ expect(serializeCaseArg(T.tuple([T.literal(1), T.literal(2)]))).toBe("[1, 2]");
133
+ expect(serializeCaseArg(T.tuple([]))).toBe("[]");
134
+ expect(serializeCaseArg(T.object({ a: T.literal(1) }))).toBe("{ a: 1 }");
135
+ expect(serializeCaseArg(T.object({}))).toBe("{}");
136
+ expect(serializeCaseArg(T.object({ "a-b": T.string }))).toBe('{ "a-b": T.string }');
137
+ expect(serializeCaseArg(T.union(T.number, T.string))).toBe("T.union(T.number, T.string)");
138
+ });
139
+
140
+ it("round-trips through parseTypeValueExpr: equivalent parse and idempotent serialize", () => {
141
+ for (const { label, value } of matrix) {
142
+ const s = serializeCaseArg(value);
143
+ expect(s, label).not.toBeNull();
144
+ const parsed = parseTypeValueExpr(s!);
145
+ // 性质 1:serialize(parse(serialize(x))) === serialize(x)
146
+ expect(serializeCaseArg(parsed), label).toBe(s);
147
+ // 性质 2:parse 结果与原值结构等价(kind / 字面量值 / 成员)
148
+ expect(structurallyEqual(parsed, value), label).toBe(true);
149
+ }
150
+ });
151
+
152
+ it("returns null for kinds the grammar cannot express, including nested", () => {
153
+ const nulls: Array<{ label: string; value: TypeValue }> = [
154
+ { label: "bigint", value: T.bigint },
155
+ { label: "symbol", value: T.symbol },
156
+ { label: "function", value: dummyFn() },
157
+ { label: "promise", value: T.promise(T.number) },
158
+ { label: "instance", value: T.instanceOf("Error") },
159
+ { label: "array of promise", value: T.array(T.promise(T.number)) },
160
+ { label: "tuple with instance", value: T.tuple([T.instanceOf("Date")]) },
161
+ { label: "object with bigint", value: T.object({ n: T.bigint }) },
162
+ { label: "union with symbol", value: T.union(T.number, T.symbol) },
163
+ { label: "object with function value", value: T.object({ cb: dummyFn() }) },
164
+ ];
165
+ for (const { label, value } of nulls) {
166
+ expect(serializeCaseArg(value), label).toBeNull();
167
+ }
168
+ });
169
+
170
+ it("returns null for string/number literals that cannot survive the round-trip", () => {
171
+ // 两种引号并存:文法无转义机制,无法安全包裹
172
+ expect(serializeCaseArg(T.literal(`a"b'c`))).toBeNull();
173
+ // 结构字符:破坏参数切分 / 括号配对 / 对象键冒号定位 / JSDoc 终止
174
+ expect(serializeCaseArg(T.literal("a,b"))).toBeNull();
175
+ expect(serializeCaseArg(T.literal("a(b"))).toBeNull();
176
+ expect(serializeCaseArg(T.literal("a}b"))).toBeNull();
177
+ expect(serializeCaseArg(T.literal("a*/b"))).toBeNull();
178
+ expect(serializeCaseArg(T.literal("a\nb"))).toBeNull();
179
+ // 科学计数法 / 非有限数:文法的数字正则不收
180
+ expect(serializeCaseArg(T.literal(NaN))).toBeNull();
181
+ expect(serializeCaseArg(T.literal(Infinity))).toBeNull();
182
+ expect(serializeCaseArg(T.literal(1e21))).toBeNull();
183
+ expect(serializeCaseArg(T.literal(1e-7))).toBeNull();
184
+ // 对象键含双引号 / 冒号 / 首尾引号
185
+ expect(serializeCaseArg(T.object({ 'a"b': T.number }))).toBeNull();
186
+ expect(serializeCaseArg(T.object({ "a:b": T.number }))).toBeNull();
187
+ expect(serializeCaseArg(T.object({ "'q'": T.number }))).toBeNull();
188
+ });
189
+ });
190
+
191
+ describe("buildCaseDirective", () => {
192
+ it("builds a single directive line without trailing newline", () => {
193
+ expect(buildCaseDirective("call@L3", [T.number, T.literal(1)])).toBe(
194
+ ' * @nudo:case "call@L3" (T.number, 1)',
195
+ );
196
+ expect(buildCaseDirective("x", [])).toBe(' * @nudo:case "x" ()');
197
+ });
198
+
199
+ it("propagates unserializable args as null", () => {
200
+ expect(buildCaseDirective("call@L3", [T.number, T.promise(T.string)])).toBeNull();
201
+ expect(buildCaseDirective("call@L3", [T.instanceOf("Error")])).toBeNull();
202
+ });
203
+
204
+ it("rejects names the case-name regex cannot carry", () => {
205
+ expect(buildCaseDirective('bad"name', [T.number])).toBeNull();
206
+ expect(buildCaseDirective("bad\nname", [T.number])).toBeNull();
207
+ });
208
+ });
209
+
210
+ describe("stripGeneratedCaseDirectives", () => {
211
+ it("removes call@ case lines and the orphaned empty JSDoc block", () => {
212
+ const source = `/**
213
+ * @nudo:case "call@L3" (1, 2)
214
+ * @nudo:case "call@L4" (T.string)
215
+ */
216
+ function add(a, b) {
217
+ return a + b;
218
+ }
219
+ `;
220
+ const { source: out, removed } = stripGeneratedCaseDirectives(source);
221
+ expect(removed).toEqual(["call@L3", "call@L4"]);
222
+ expect(out).toBe(`function add(a, b) {
223
+ return a + b;
224
+ }
225
+ `);
226
+ });
227
+
228
+ it("keeps the block when it still has prose or other @nudo directives", () => {
229
+ const source = `/**
230
+ * Adds two numbers.
231
+ * @nudo:case "call@L4" (1, 2)
232
+ * @nudo:pure
233
+ */
234
+ function add(a, b) {}
235
+ `;
236
+ const { source: out, removed } = stripGeneratedCaseDirectives(source);
237
+ expect(removed).toEqual(["call@L4"]);
238
+ expect(out).toBe(`/**
239
+ * Adds two numbers.
240
+ * @nudo:pure
241
+ */
242
+ function add(a, b) {}
243
+ `);
244
+ });
245
+
246
+ it("never touches hand-written cases or plain comments", () => {
247
+ const source = `/**
248
+ * @nudo:case "manual" (1, 2)
249
+ */
250
+ function add(a, b) {}
251
+
252
+ /* plain block comment */
253
+ // @nudo:case "other" (T.number)
254
+ const x = 1;
255
+ `;
256
+ const { source: out, removed } = stripGeneratedCaseDirectives(source);
257
+ expect(removed).toEqual([]);
258
+ expect(out).toBe(source);
259
+ });
260
+
261
+ it("deletes hand-written cases that use the reserved call@ prefix (documented behavior)", () => {
262
+ const source = `/**
263
+ * @nudo:case "call@custom" (1)
264
+ * keep me
265
+ */
266
+ function add(a, b) {}
267
+ `;
268
+ const { source: out, removed } = stripGeneratedCaseDirectives(source);
269
+ expect(removed).toEqual(["call@custom"]);
270
+ expect(out).toBe(`/**
271
+ * keep me
272
+ */
273
+ function add(a, b) {}
274
+ `);
275
+ });
276
+ });
277
+
278
+ describe("insertGeneratedCaseDirectives", () => {
279
+ it("skips hand-written cases with priority over everything else", () => {
280
+ const source = `/**
281
+ * @nudo:case "manual" (1)
282
+ */
283
+ function add(a, b) {
284
+ return a + b;
285
+ }
286
+ `;
287
+ const result = insertGeneratedCaseDirectives(
288
+ source,
289
+ makeAnalysis([makeFn("add", 4, [{ name: "call@L9", args: [T.number], source: "callsite" }])]),
290
+ );
291
+ expect(result.changed).toBe(false);
292
+ expect(result.written).toEqual([]);
293
+ expect(result.skipped).toEqual([{ fn: "add", reason: "hand-written" }]);
294
+ expect(result.source).toBe(source);
295
+ });
296
+
297
+ it("skips functions that already carry generated directives", () => {
298
+ const source = `/**
299
+ * @nudo:case "call@L2" (1)
300
+ */
301
+ function add(a, b) {
302
+ return a + b;
303
+ }
304
+ `;
305
+ const result = insertGeneratedCaseDirectives(
306
+ source,
307
+ makeAnalysis([makeFn("add", 4, [{ name: "call@L9", args: [T.number], source: "callsite" }])]),
308
+ );
309
+ expect(result.changed).toBe(false);
310
+ expect(result.skipped).toEqual([{ fn: "add", reason: "already-generated" }]);
311
+ expect(result.source).toBe(source);
312
+ });
313
+
314
+ it("skips flagged functions: skipped / noDeclaration / entryOnly", () => {
315
+ const source = `function a(x) { return x; }
316
+ `;
317
+ const result = insertGeneratedCaseDirectives(
318
+ source,
319
+ makeAnalysis([
320
+ makeFn("a", 1, [{ name: "call@L1", args: [T.number], source: "callsite" }], { skipped: true }),
321
+ makeFn("b", 1, [{ name: "call@L1", args: [T.number], source: "callsite" }], { noDeclaration: true }),
322
+ makeFn("c", 1, [{ name: "entry@L1", args: [T.unknown] }], { entryOnly: true }),
323
+ ]),
324
+ );
325
+ expect(result.changed).toBe(false);
326
+ expect(result.skipped).toEqual([
327
+ { fn: "a", reason: "skipped" },
328
+ { fn: "b", reason: "no-declaration" },
329
+ { fn: "c", reason: "entry-only" },
330
+ ]);
331
+ });
332
+
333
+ it("skips the whole function when no callsite case is serializable", () => {
334
+ const source = `function add(a, b) {
335
+ return a + b;
336
+ }
337
+ `;
338
+ const result = insertGeneratedCaseDirectives(
339
+ source,
340
+ makeAnalysis([
341
+ makeFn("add", 1, [
342
+ { name: "call@L2", args: [T.promise(T.number)], source: "callsite" },
343
+ { name: "call@L3", args: [T.instanceOf("Error")], source: "callsite" },
344
+ ]),
345
+ ]),
346
+ );
347
+ expect(result.changed).toBe(false);
348
+ expect(result.written).toEqual([]);
349
+ expect(result.skipped).toEqual([
350
+ { fn: "add", reason: "no-serializable-cases", detail: "case call@L2 not serializable" },
351
+ { fn: "add", reason: "no-serializable-cases", detail: "case call@L3 not serializable" },
352
+ ]);
353
+ expect(result.source).toBe(source);
354
+ });
355
+
356
+ it("writes the serializable subset and records dropped cases individually", () => {
357
+ const source = `function add(a, b) {
358
+ return a + b;
359
+ }
360
+ `;
361
+ const result = insertGeneratedCaseDirectives(
362
+ source,
363
+ makeAnalysis([
364
+ makeFn("add", 1, [
365
+ { name: "call@L2", args: [T.number, T.literal(2)], source: "callsite" },
366
+ { name: "call@L3", args: [T.promise(T.number)], source: "callsite" },
367
+ ]),
368
+ ]),
369
+ );
370
+ expect(result.changed).toBe(true);
371
+ expect(result.written).toEqual([{ fn: "add", cases: ["call@L2"] }]);
372
+ expect(result.skipped).toEqual([
373
+ { fn: "add", reason: "no-serializable-cases", detail: "case call@L3 not serializable" },
374
+ ]);
375
+ expect(result.source).toBe(`/**
376
+ * @nudo:case "call@L2" (T.number, 2)
377
+ */
378
+ function add(a, b) {
379
+ return a + b;
380
+ }
381
+ `);
382
+ });
383
+
384
+ it("reports no-serializable-cases when a function has no callsite cases at all", () => {
385
+ const source = `function add(a, b) {
386
+ return a + b;
387
+ }
388
+ `;
389
+ const result = insertGeneratedCaseDirectives(
390
+ source,
391
+ makeAnalysis([makeFn("add", 1, [{ name: "entry@L1", args: [] }])]),
392
+ );
393
+ expect(result.changed).toBe(false);
394
+ expect(result.skipped).toEqual([
395
+ { fn: "add", reason: "no-serializable-cases", detail: "no callsite cases" },
396
+ ]);
397
+ });
398
+
399
+ it("creates a fresh three-line JSDoc block above the declaration", () => {
400
+ const source = `function add(a, b) {
401
+ return a + b;
402
+ }
403
+ `;
404
+ const result = insertGeneratedCaseDirectives(
405
+ source,
406
+ makeAnalysis([
407
+ makeFn("add", 1, [
408
+ { name: "call@L4", args: [T.number, T.string], source: "callsite" },
409
+ { name: "call@L5", args: [T.literal("x")], source: "callsite" },
410
+ ]),
411
+ ]),
412
+ );
413
+ expect(result.source).toBe(`/**
414
+ * @nudo:case "call@L4" (T.number, T.string)
415
+ * @nudo:case "call@L5" ("x")
416
+ */
417
+ function add(a, b) {
418
+ return a + b;
419
+ }
420
+ `);
421
+ });
422
+
423
+ it("injects into an existing JSDoc block right after the /** line", () => {
424
+ const source = `/**
425
+ * Adds numbers.
426
+ * @nudo:pure
427
+ */
428
+ function add(a, b) {
429
+ return a + b;
430
+ }
431
+ `;
432
+ const result = insertGeneratedCaseDirectives(
433
+ source,
434
+ makeAnalysis([makeFn("add", 5, [{ name: "call@L9", args: [T.number], source: "callsite" }])]),
435
+ );
436
+ expect(result.source).toBe(`/**
437
+ * @nudo:case "call@L9" (T.number)
438
+ * Adds numbers.
439
+ * @nudo:pure
440
+ */
441
+ function add(a, b) {
442
+ return a + b;
443
+ }
444
+ `);
445
+ });
446
+
447
+ it("indents the new block by the declaration column (babel 0-based)", () => {
448
+ const source = `function outer() {
449
+ function inner(x) {
450
+ return x;
451
+ }
452
+ return inner(1);
453
+ }
454
+ `;
455
+ const result = insertGeneratedCaseDirectives(
456
+ source,
457
+ makeAnalysis([makeFn("inner", 2, [{ name: "call@L5", args: [T.number], source: "callsite" }], { column: 2 })]),
458
+ );
459
+ expect(result.source).toBe(`function outer() {
460
+ /**
461
+ * @nudo:case "call@L5" (T.number)
462
+ */
463
+ function inner(x) {
464
+ return x;
465
+ }
466
+ return inner(1);
467
+ }
468
+ `);
469
+ });
470
+
471
+ it("applies multiple function edits bottom-up without line drift", () => {
472
+ const source = `function a1(x) {
473
+ return x;
474
+ }
475
+ a1(1);
476
+
477
+ function b2(x) {
478
+ return x;
479
+ }
480
+ b2("s");
481
+ `;
482
+ const result = insertGeneratedCaseDirectives(
483
+ source,
484
+ makeAnalysis([
485
+ makeFn("a1", 1, [{ name: "call@L4", args: [T.number], source: "callsite" }]),
486
+ makeFn("b2", 6, [{ name: "call@L9", args: [T.string], source: "callsite" }]),
487
+ ]),
488
+ );
489
+ expect(result.source).toBe(`/**
490
+ * @nudo:case "call@L4" (T.number)
491
+ */
492
+ function a1(x) {
493
+ return x;
494
+ }
495
+ a1(1);
496
+
497
+ /**
498
+ * @nudo:case "call@L9" (T.string)
499
+ */
500
+ function b2(x) {
501
+ return x;
502
+ }
503
+ b2("s");
504
+ `);
505
+ expect(result.written).toEqual([
506
+ { fn: "a1", cases: ["call@L4"] },
507
+ { fn: "b2", cases: ["call@L9"] },
508
+ ]);
509
+ });
510
+
511
+ it("round-trips at source level: inserted directives parse back to equivalent TypeValues", () => {
512
+ const source = `function add(a, b) {
513
+ return a + b;
514
+ }
515
+ `;
516
+ const argTuple = T.object({ xs: T.array(T.union(T.number, T.literal(null))) });
517
+ const result = insertGeneratedCaseDirectives(
518
+ source,
519
+ makeAnalysis([
520
+ makeFn("add", 1, [{ name: "call@L1", args: [T.literal(1), argTuple], source: "callsite" }]),
521
+ ]),
522
+ );
523
+ const fwd = extractDirectives(parse(result.source)).find((f) => f.name === "add")!;
524
+ const caseDirs = fwd.directives.filter((d): d is CaseDirective => d.kind === "case");
525
+ expect(caseDirs.map((d) => d.name)).toEqual(["call@L1"]);
526
+ expect(caseDirs[0].args).toHaveLength(2);
527
+ expect(structurallyEqual(caseDirs[0].args[0], T.literal(1))).toBe(true);
528
+ expect(structurallyEqual(caseDirs[0].args[1], argTuple)).toBe(true);
529
+ });
530
+ });
531
+
532
+ describe("insertGeneratedCaseDirectives with a real analyzeFile result", () => {
533
+ const SOURCE = `function add(a, b) {
534
+ return a + b;
535
+ }
536
+ const r1 = add(1, 2);
537
+ const r2 = add("x", "y");
538
+
539
+ function unused(u) {
540
+ return u;
541
+ }
542
+ `;
543
+
544
+ it("freezes synthesized callsite cases and skips entry-only functions", () => {
545
+ const analysis = analyzeFile("/test/emit-integration.js", SOURCE);
546
+ const result = insertGeneratedCaseDirectives(SOURCE, analysis);
547
+
548
+ expect(result.changed).toBe(true);
549
+ expect(result.written).toEqual([{ fn: "add", cases: ["call@L4", "call@L5"] }]);
550
+ expect(result.skipped.map((s) => s.reason)).toContain("entry-only");
551
+
552
+ // 源码级往返:指令可被 parser 解析回等价 TypeValue
553
+ const fwd = extractDirectives(parse(result.source)).find((f) => f.name === "add")!;
554
+ const caseDirs = fwd.directives.filter((d): d is CaseDirective => d.kind === "case");
555
+ expect(caseDirs.map((c) => c.name)).toEqual(["call@L4", "call@L5"]);
556
+ expect(structurallyEqual(caseDirs[0].args[0], T.literal(1))).toBe(true);
557
+ expect(structurallyEqual(caseDirs[0].args[1], T.literal(2))).toBe(true);
558
+ expect(structurallyEqual(caseDirs[1].args[0], T.literal("x"))).toBe(true);
559
+ expect(structurallyEqual(caseDirs[1].args[1], T.literal("y"))).toBe(true);
560
+ });
561
+
562
+ it("strip(insert(x)) recovers the original source", () => {
563
+ const analysis = analyzeFile("/test/emit-integration.js", SOURCE);
564
+ const { source: emitted } = insertGeneratedCaseDirectives(SOURCE, analysis);
565
+ const { source: recovered, removed } = stripGeneratedCaseDirectives(emitted);
566
+ expect(removed).toEqual(["call@L4", "call@L5"]);
567
+ expect(recovered).toBe(SOURCE);
568
+ });
569
+
570
+ it("re-inserting over an already-generated source reports already-generated", () => {
571
+ const analysis = analyzeFile("/test/emit-integration.js", SOURCE);
572
+ const first = insertGeneratedCaseDirectives(SOURCE, analysis);
573
+ const second = insertGeneratedCaseDirectives(first.source, analysis);
574
+ expect(second.changed).toBe(false);
575
+ expect(second.written).toEqual([]);
576
+ expect(second.skipped.map((s) => s.reason)).toContain("already-generated");
577
+ });
578
+ });
579
+
580
+ describe("unifiedDiff", () => {
581
+ it("returns empty string for identical inputs", () => {
582
+ expect(unifiedDiff("a\nb\n", "a\nb\n", "f.txt")).toBe("");
583
+ });
584
+
585
+ it("formats a single modified line with 3 lines of context", () => {
586
+ const a = ["01", "02", "03", "04", "05", "06", "07", "08"].join("\n");
587
+ const b = ["01", "02", "03", "X4", "05", "06", "07", "08"].join("\n");
588
+ expect(unifiedDiff(a, b, "t.txt")).toBe(
589
+ [`--- a/t.txt`, `+++ b/t.txt`, `@@ -1,7 +1,7 @@`, ` 01`, ` 02`, ` 03`, `-04`, `+X4`, ` 05`, ` 06`, ` 07`, ""].join("\n"),
590
+ );
591
+ });
592
+
593
+ it("omits the count when it is exactly 1", () => {
594
+ expect(unifiedDiff("one", "two", "t.txt")).toBe(
595
+ [`--- a/t.txt`, `+++ b/t.txt`, `@@ -1 +1 @@`, `-one`, `+two`, ""].join("\n"),
596
+ );
597
+ });
598
+
599
+ it("formats pure insertion with old-side count 0", () => {
600
+ expect(unifiedDiff("x\ny\nz", "new\nx\ny\nz", "t.txt")).toBe(
601
+ [`--- a/t.txt`, `+++ b/t.txt`, `@@ -1,3 +1,4 @@`, `+new`, ` x`, ` y`, ` z`, ""].join("\n"),
602
+ );
603
+ });
604
+
605
+ it("formats pure deletion", () => {
606
+ expect(unifiedDiff("a\nb", "a", "t.txt")).toBe(
607
+ [`--- a/t.txt`, `+++ b/t.txt`, `@@ -1,2 +1 @@`, ` a`, `-b`, ""].join("\n"),
608
+ );
609
+ });
610
+
611
+ it("splits hunks separated by more than 2*context equal lines", () => {
612
+ const a = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, "0")).join("\n");
613
+ const b = a.replace("02", "X2").replace("11", "Y11");
614
+ expect(unifiedDiff(a, b, "t.txt")).toBe(
615
+ [
616
+ `--- a/t.txt`,
617
+ `+++ b/t.txt`,
618
+ `@@ -1,5 +1,5 @@`,
619
+ ` 01`,
620
+ `-02`,
621
+ `+X2`,
622
+ ` 03`,
623
+ ` 04`,
624
+ ` 05`,
625
+ `@@ -8,5 +8,5 @@`,
626
+ ` 08`,
627
+ ` 09`,
628
+ ` 10`,
629
+ `-11`,
630
+ `+Y11`,
631
+ ` 12`,
632
+ "",
633
+ ].join("\n"),
634
+ );
635
+ });
636
+ });
@@ -0,0 +1,54 @@
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, collectDependencySpecs } from "../static-imports.ts";
6
+ import { parse } from "@nudojs/parser";
7
+
8
+ describe("CJS require static resolution", () => {
9
+ it("follows require('./x')", () => {
10
+ const dir = mkdtempSync(join(tmpdir(), "nudo-cjs-"));
11
+ try {
12
+ writeFileSync(
13
+ join(dir, "math.js"),
14
+ `function add(a, b) { return a + b; }\nexports.add = add;\nexports.scale = function (x) { return add(x, 1); };\n`,
15
+ );
16
+ writeFileSync(
17
+ join(dir, "index.js"),
18
+ `const { add } = require("./math.js");\nexports.double = function (x) { return add(x, x); };\n`,
19
+ );
20
+ const graph = collectStaticImports(join(dir, "index.js"));
21
+ expect(graph.size).toBe(2);
22
+ const index = graph.get(join(dir, "index.js"))!;
23
+ expect(index.named.has("double")).toBe(true);
24
+ const math = graph.get(join(dir, "math.js"))!;
25
+ expect(math.named.has("add")).toBe(true);
26
+ expect(math.poly.get("add")?.display).toContain("A1");
27
+ } finally {
28
+ rmSync(dir, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ it("collectDependencySpecs finds require strings", () => {
33
+ const ast = parse(`const a = require("./a.js");\nconst b = require("./b.js");\n`);
34
+ const specs = collectDependencySpecs(ast);
35
+ expect(specs).toContain("./a.js");
36
+ expect(specs).toContain("./b.js");
37
+ });
38
+
39
+ it("module.exports = fn registers export", () => {
40
+ const dir = mkdtempSync(join(tmpdir(), "nudo-cjs2-"));
41
+ try {
42
+ writeFileSync(
43
+ join(dir, "lib.js"),
44
+ `function hello() { return "hi"; }\nmodule.exports = hello;\n`,
45
+ );
46
+ writeFileSync(join(dir, "app.js"), `const hello = require("./lib.js");\nexports.f = hello;\n`);
47
+ const graph = collectStaticImports(join(dir, "app.js"));
48
+ const lib = graph.get(join(dir, "lib.js"))!;
49
+ expect(lib.named.has("hello")).toBe(true);
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ });
54
+ });