@elench/testkit 0.1.63 → 0.1.65

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 (90) hide show
  1. package/lib/coverage/backend-discovery.mjs +6 -30
  2. package/lib/coverage/evidence.mjs +15 -3
  3. package/lib/coverage/evidence.test.mjs +13 -4
  4. package/lib/coverage/fs-walk.mjs +2 -2
  5. package/lib/coverage/graph-builder.mjs +23 -24
  6. package/lib/coverage/next-ir-to-graph.mjs +240 -0
  7. package/node_modules/@elench/next-analysis/package.json +14 -0
  8. package/node_modules/@elench/next-analysis/src/api-routes.mjs +81 -0
  9. package/node_modules/@elench/next-analysis/src/api-routes.test.mjs +22 -0
  10. package/node_modules/@elench/next-analysis/src/app-root.mjs +7 -0
  11. package/node_modules/@elench/next-analysis/src/backend-links.mjs +31 -0
  12. package/node_modules/@elench/next-analysis/src/index.mjs +21 -0
  13. package/node_modules/@elench/next-analysis/src/pages.mjs +68 -0
  14. package/node_modules/@elench/next-analysis/src/project.mjs +94 -0
  15. package/node_modules/@elench/next-analysis/src/project.test.mjs +35 -0
  16. package/node_modules/@elench/next-analysis/src/route-tree.mjs +621 -0
  17. package/node_modules/@elench/next-analysis/src/routes.mjs +41 -0
  18. package/node_modules/@elench/next-analysis/src/routes.test.mjs +25 -0
  19. package/node_modules/@elench/next-analysis/src/server-actions.mjs +53 -0
  20. package/node_modules/@elench/next-analysis/src/server-actions.test.mjs +37 -0
  21. package/node_modules/@elench/next-analysis/src/shared.mjs +209 -0
  22. package/node_modules/@elench/next-analysis/src/swc.mjs +388 -0
  23. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  24. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  25. package/node_modules/@elench/ts-analysis/package.json +10 -0
  26. package/node_modules/@elench/ts-analysis/src/callables.mjs +135 -0
  27. package/node_modules/@elench/ts-analysis/src/callables.test.mjs +55 -0
  28. package/node_modules/@elench/ts-analysis/src/exports.mjs +69 -0
  29. package/node_modules/@elench/ts-analysis/src/exports.test.mjs +50 -0
  30. package/node_modules/@elench/ts-analysis/src/index.mjs +14 -0
  31. package/node_modules/@elench/ts-analysis/src/jsx.mjs +69 -0
  32. package/node_modules/@elench/ts-analysis/src/jsx.test.mjs +43 -0
  33. package/node_modules/@elench/ts-analysis/src/project.mjs +100 -0
  34. package/node_modules/@elench/ts-analysis/src/project.test.mjs +54 -0
  35. package/node_modules/@elench/ts-analysis/src/requests.mjs +141 -0
  36. package/node_modules/@elench/ts-analysis/src/requests.test.mjs +35 -0
  37. package/node_modules/@elench/ts-analysis/src/resolution.mjs +53 -0
  38. package/node_modules/@elench/ts-analysis/src/shared.mjs +32 -0
  39. package/node_modules/@elench/ts-analysis/src/syntax.mjs +27 -0
  40. package/node_modules/@next/routing/README.md +91 -0
  41. package/node_modules/@next/routing/dist/__tests__/captures.test.d.ts +1 -0
  42. package/node_modules/@next/routing/dist/__tests__/conditions.test.d.ts +1 -0
  43. package/node_modules/@next/routing/dist/__tests__/dynamic-after-rewrites.test.d.ts +1 -0
  44. package/node_modules/@next/routing/dist/__tests__/i18n-resolve-routes.test.d.ts +1 -0
  45. package/node_modules/@next/routing/dist/__tests__/i18n.test.d.ts +1 -0
  46. package/node_modules/@next/routing/dist/__tests__/middleware.test.d.ts +1 -0
  47. package/node_modules/@next/routing/dist/__tests__/normalize-next-data.test.d.ts +1 -0
  48. package/node_modules/@next/routing/dist/__tests__/redirects.test.d.ts +1 -0
  49. package/node_modules/@next/routing/dist/__tests__/resolve-routes.test.d.ts +1 -0
  50. package/node_modules/@next/routing/dist/__tests__/rewrites.test.d.ts +1 -0
  51. package/node_modules/@next/routing/dist/destination.d.ts +22 -0
  52. package/node_modules/@next/routing/dist/i18n.d.ts +48 -0
  53. package/node_modules/@next/routing/dist/index.d.ts +5 -0
  54. package/node_modules/@next/routing/dist/index.js +1 -0
  55. package/node_modules/@next/routing/dist/matchers.d.ts +12 -0
  56. package/node_modules/@next/routing/dist/middleware.d.ts +12 -0
  57. package/node_modules/@next/routing/dist/next-data.d.ts +10 -0
  58. package/node_modules/@next/routing/dist/resolve-routes.d.ts +2 -0
  59. package/node_modules/@next/routing/dist/types.d.ts +97 -0
  60. package/node_modules/@next/routing/package.json +39 -0
  61. package/node_modules/@swc/core/README.md +100 -0
  62. package/node_modules/@swc/core/Visitor.d.ts +218 -0
  63. package/node_modules/@swc/core/Visitor.js +1399 -0
  64. package/node_modules/@swc/core/binding.d.ts +59 -0
  65. package/node_modules/@swc/core/binding.js +368 -0
  66. package/node_modules/@swc/core/index.d.ts +120 -0
  67. package/node_modules/@swc/core/index.js +443 -0
  68. package/node_modules/@swc/core/package.json +120 -0
  69. package/node_modules/@swc/core/postinstall.js +148 -0
  70. package/node_modules/@swc/core/spack.d.ts +51 -0
  71. package/node_modules/@swc/core/spack.js +87 -0
  72. package/node_modules/@swc/core/util.d.ts +1 -0
  73. package/node_modules/@swc/core/util.js +104 -0
  74. package/node_modules/@swc/core-linux-x64-gnu/README.md +3 -0
  75. package/node_modules/@swc/core-linux-x64-gnu/package.json +46 -0
  76. package/node_modules/@swc/core-linux-x64-gnu/swc.linux-x64-gnu.node +0 -0
  77. package/node_modules/@swc/counter/CHANGELOG.md +7 -0
  78. package/node_modules/@swc/counter/README.md +7 -0
  79. package/node_modules/@swc/counter/index.js +1 -0
  80. package/node_modules/@swc/counter/package.json +27 -0
  81. package/node_modules/@swc/types/LICENSE +201 -0
  82. package/node_modules/@swc/types/README.md +4 -0
  83. package/node_modules/@swc/types/assumptions.d.ts +92 -0
  84. package/node_modules/@swc/types/assumptions.js +2 -0
  85. package/node_modules/@swc/types/index.d.ts +2049 -0
  86. package/node_modules/@swc/types/index.js +2 -0
  87. package/node_modules/@swc/types/package.json +40 -0
  88. package/package.json +7 -3
  89. package/lib/coverage/next-discovery.mjs +0 -205
  90. package/lib/coverage/next-static-analysis.mjs +0 -1047
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@elench/ts-analysis",
3
+ "version": "0.1.65",
4
+ "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.mjs",
8
+ "./package.json": "./package.json"
9
+ }
10
+ }
@@ -0,0 +1,135 @@
1
+ import ts from "typescript";
2
+ import { extractStringLiteral } from "./syntax.mjs";
3
+
4
+ export function collectImports(sourceFile, options) {
5
+ const imports = new Map();
6
+ for (const statement of sourceFile.statements) {
7
+ if (!ts.isImportDeclaration(statement) || !statement.importClause) continue;
8
+ const specifier = extractStringLiteral(statement.moduleSpecifier);
9
+ if (!specifier) continue;
10
+ const resolvedFilePath = options.resolveImportPath(specifier);
11
+ const sourceContent = resolvedFilePath ? options.readSourceFile(resolvedFilePath) : null;
12
+ const serverActionImport = Boolean(sourceContent && options.isServerActionFile(sourceContent));
13
+
14
+ if (statement.importClause.name) {
15
+ imports.set(statement.importClause.name.text, {
16
+ importedName: "default",
17
+ specifier,
18
+ resolvedFilePath,
19
+ isServerAction: serverActionImport,
20
+ });
21
+ }
22
+
23
+ const bindings = statement.importClause.namedBindings;
24
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
25
+ for (const element of bindings.elements) {
26
+ const localName = element.name.text;
27
+ const importedName = element.propertyName?.text || localName;
28
+ imports.set(localName, {
29
+ importedName,
30
+ specifier,
31
+ resolvedFilePath,
32
+ isServerAction: serverActionImport,
33
+ });
34
+ }
35
+ }
36
+ return imports;
37
+ }
38
+
39
+ export function collectTopLevelFunctions(sourceFile) {
40
+ const functions = new Map();
41
+ for (const statement of sourceFile.statements) {
42
+ if (ts.isFunctionDeclaration(statement) && statement.name) {
43
+ functions.set(statement.name.text, statement);
44
+ continue;
45
+ }
46
+
47
+ if (!ts.isVariableStatement(statement)) continue;
48
+ for (const declaration of statement.declarationList.declarations) {
49
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
50
+ if (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) {
51
+ functions.set(declaration.name.text, declaration.initializer);
52
+ }
53
+ }
54
+ }
55
+ return functions;
56
+ }
57
+
58
+ export function collectExportedCallables(sourceFile, topLevelFunctions) {
59
+ const exports = new Map();
60
+ for (const statement of sourceFile.statements) {
61
+ if (ts.isFunctionDeclaration(statement) && statement.name && hasExportModifier(statement.modifiers)) {
62
+ exports.set(statement.name.text, statement);
63
+ continue;
64
+ }
65
+
66
+ if (!ts.isVariableStatement(statement) || !hasExportModifier(statement.modifiers)) continue;
67
+ for (const declaration of statement.declarationList.declarations) {
68
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
69
+ if (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) {
70
+ exports.set(declaration.name.text, declaration.initializer);
71
+ } else if (ts.isIdentifier(declaration.initializer)) {
72
+ const referenced = topLevelFunctions.get(declaration.initializer.text);
73
+ if (referenced) exports.set(declaration.name.text, referenced);
74
+ }
75
+ }
76
+ }
77
+ return exports;
78
+ }
79
+
80
+ export function collectScopeLocalFunctions(node) {
81
+ const functions = new Map();
82
+ const callableBody = "body" in node ? node.body : null;
83
+ if (!callableBody) return functions;
84
+
85
+ const visit = (child) => {
86
+ if (ts.isFunctionDeclaration(child) && child.name) {
87
+ functions.set(child.name.text, child);
88
+ if (child !== callableBody) return;
89
+ }
90
+
91
+ if (
92
+ child !== callableBody &&
93
+ (ts.isArrowFunction(child) || ts.isFunctionExpression(child))
94
+ ) {
95
+ return;
96
+ }
97
+
98
+ if (ts.isVariableDeclaration(child) && ts.isIdentifier(child.name) && child.initializer) {
99
+ if (ts.isArrowFunction(child.initializer) || ts.isFunctionExpression(child.initializer)) {
100
+ functions.set(child.name.text, child.initializer);
101
+ }
102
+ }
103
+
104
+ ts.forEachChild(child, visit);
105
+ };
106
+
107
+ visit(callableBody);
108
+ return functions;
109
+ }
110
+
111
+ export function findDefaultExportCallable(sourceFile, localFunctions) {
112
+ for (const statement of sourceFile.statements) {
113
+ if (ts.isFunctionDeclaration(statement) && hasDefaultModifier(statement.modifiers)) {
114
+ return statement;
115
+ }
116
+
117
+ if (ts.isExportAssignment(statement)) {
118
+ if (ts.isIdentifier(statement.expression)) {
119
+ return localFunctions.get(statement.expression.text) || null;
120
+ }
121
+ if (ts.isArrowFunction(statement.expression) || ts.isFunctionExpression(statement.expression)) {
122
+ return statement.expression;
123
+ }
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ function hasExportModifier(modifiers) {
130
+ return Boolean(modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
131
+ }
132
+
133
+ function hasDefaultModifier(modifiers) {
134
+ return Boolean(modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword));
135
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ collectExportedCallables,
4
+ collectImports,
5
+ collectScopeLocalFunctions,
6
+ collectTopLevelFunctions,
7
+ createSourceFile,
8
+ findDefaultExportCallable,
9
+ } from "./index.mjs";
10
+
11
+ describe("@elench/ts-analysis callables", () => {
12
+ it("collects top-level, exported, and scope-local callables", () => {
13
+ const content = `
14
+ import { saveSettings as save } from "./actions";
15
+
16
+ function helper() {}
17
+ const localArrow = () => {};
18
+
19
+ export function namedExport() {}
20
+ export const exportedArrow = () => {};
21
+
22
+ export default function Page() {
23
+ function nestedDecl() {}
24
+ const nestedArrow = () => {};
25
+ return <button onClick={nestedArrow}>Save</button>;
26
+ }
27
+ `;
28
+ const sourceFile = createSourceFile("page.tsx", content);
29
+ const topLevel = collectTopLevelFunctions(sourceFile);
30
+ const exported = collectExportedCallables(sourceFile, topLevel);
31
+ const defaultExport = findDefaultExportCallable(sourceFile, topLevel);
32
+ const locals = collectScopeLocalFunctions(defaultExport);
33
+
34
+ expect([...topLevel.keys()]).toEqual(["helper", "localArrow", "namedExport", "exportedArrow", "Page"]);
35
+ expect([...exported.keys()]).toEqual(["namedExport", "exportedArrow", "Page"]);
36
+ expect([...locals.keys()]).toEqual(["nestedDecl", "nestedArrow"]);
37
+ });
38
+
39
+ it("collects imports with server action metadata", () => {
40
+ const sourceFile = createSourceFile(
41
+ "page.tsx",
42
+ `import action, { saveSettings as save } from "./actions"; import { Button } from "@/components/ui/button";`
43
+ );
44
+ const imports = collectImports(sourceFile, {
45
+ readSourceFile: (filePath) => (filePath === "src/app/actions.ts" ? '"use server";\nexport async function saveSettings() {}' : "export const Button = () => null;"),
46
+ resolveImportPath: (specifier) =>
47
+ specifier === "./actions" ? "src/app/actions.ts" : specifier === "@/components/ui/button" ? "src/components/ui/button.tsx" : null,
48
+ isServerActionFile: (content) => content.includes('"use server"'),
49
+ });
50
+
51
+ expect(imports.get("action")).toMatchObject({ importedName: "default", isServerAction: true });
52
+ expect(imports.get("save")).toMatchObject({ importedName: "saveSettings", isServerAction: true });
53
+ expect(imports.get("Button")).toMatchObject({ importedName: "Button", isServerAction: false });
54
+ });
55
+ });
@@ -0,0 +1,69 @@
1
+ import ts from "typescript";
2
+ import { createSourceFile } from "./syntax.mjs";
3
+
4
+ export function extractExportedMethodBodies(content, methods, filePath = "route.ts") {
5
+ const bodies = [];
6
+ for (const method of methods) {
7
+ const body = extractExportedFunctionBody(content, method, filePath);
8
+ if (body) bodies.push([method, body]);
9
+ }
10
+ return bodies;
11
+ }
12
+
13
+ export function extractExportedFunctions(content, filePath = "module.ts") {
14
+ const sourceFile = createSourceFile(filePath, content);
15
+ const exported = [];
16
+ for (const statement of sourceFile.statements) {
17
+ if (!ts.isFunctionDeclaration(statement) || !statement.name || !hasExportModifier(statement.modifiers) || !statement.body) {
18
+ continue;
19
+ }
20
+ exported.push({
21
+ name: statement.name.text,
22
+ body: bodyText(sourceFile, statement.body),
23
+ });
24
+ }
25
+ return exported;
26
+ }
27
+
28
+ export function extractExportedFunctionBody(content, exportName, filePath = "module.ts") {
29
+ const sourceFile = createSourceFile(filePath, content);
30
+ for (const statement of sourceFile.statements) {
31
+ if (!ts.isFunctionDeclaration(statement) || !statement.name || statement.name.text !== exportName) continue;
32
+ if (!hasExportModifier(statement.modifiers) || !statement.body) return null;
33
+ return bodyText(sourceFile, statement.body);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ export function readBalancedBlock(content, startIndex) {
39
+ let depth = 0;
40
+ let inSingle = false;
41
+ let inDouble = false;
42
+ let inTemplate = false;
43
+
44
+ for (let index = startIndex; index < content.length; index += 1) {
45
+ const char = content[index];
46
+ const previous = content[index - 1];
47
+ if (char === "'" && !inDouble && !inTemplate && previous !== "\\") inSingle = !inSingle;
48
+ if (char === '"' && !inSingle && !inTemplate && previous !== "\\") inDouble = !inDouble;
49
+ if (char === "`" && !inSingle && !inDouble && previous !== "\\") inTemplate = !inTemplate;
50
+ if (inSingle || inDouble || inTemplate) continue;
51
+ if (char === "{") depth += 1;
52
+ if (char === "}") {
53
+ depth -= 1;
54
+ if (depth === 0) {
55
+ return content.slice(startIndex, index + 1);
56
+ }
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function hasExportModifier(modifiers) {
63
+ return Boolean(modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
64
+ }
65
+
66
+ function bodyText(sourceFile, bodyNode) {
67
+ return sourceFile.text.slice(bodyNode.getStart(sourceFile), bodyNode.end);
68
+ }
69
+
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ extractExportedFunctionBody,
4
+ extractExportedFunctions,
5
+ extractExportedMethodBodies,
6
+ readBalancedBlock,
7
+ } from "./index.mjs";
8
+
9
+ describe("@elench/ts-analysis exports", () => {
10
+ it("extracts exported method bodies via AST", () => {
11
+ const content = `
12
+ export async function GET() {
13
+ const template = "{not a block}";
14
+ return template;
15
+ }
16
+
17
+ export async function POST() {
18
+ return { ok: true };
19
+ }
20
+ `;
21
+
22
+ expect(extractExportedMethodBodies(content, ["GET", "POST"], "route.ts")).toEqual([
23
+ ["GET", expect.stringContaining('"{not a block}"')],
24
+ ["POST", expect.stringContaining("{ ok: true }")],
25
+ ]);
26
+ });
27
+
28
+ it("extracts exported named functions and bodies", () => {
29
+ const content = `
30
+ export async function saveSettings() {
31
+ return updateSettings();
32
+ }
33
+
34
+ export function listSettings() {
35
+ return [];
36
+ }
37
+ `;
38
+
39
+ expect(extractExportedFunctions(content, "actions.ts")).toEqual([
40
+ { name: "saveSettings", body: expect.stringContaining("updateSettings") },
41
+ { name: "listSettings", body: expect.stringContaining("return [];") },
42
+ ]);
43
+ expect(extractExportedFunctionBody(content, "saveSettings", "actions.ts")).toContain("updateSettings");
44
+ });
45
+
46
+ it("reads a balanced block from a brace offset", () => {
47
+ const content = "before { const tpl = `{nested}`; return tpl; } after";
48
+ expect(readBalancedBlock(content, content.indexOf("{"))).toBe("{ const tpl = `{nested}`; return tpl; }");
49
+ });
50
+ });
@@ -0,0 +1,14 @@
1
+ export { DEFAULT_DYNAMIC_SEGMENT_TOKEN, normalizePath } from "./shared.mjs";
2
+ export { createAnalysisProject, findNearestTsConfig, loadTsConfig } from "./project.mjs";
3
+ export { resolveImportToSourceFile, resolveSourceCandidate } from "./resolution.mjs";
4
+ export {
5
+ collectExportedCallables,
6
+ collectImports,
7
+ collectScopeLocalFunctions,
8
+ collectTopLevelFunctions,
9
+ findDefaultExportCallable,
10
+ } from "./callables.mjs";
11
+ export { collectJsxAttributes, extractJsxLabel, flattenJsxText, walkJsx } from "./jsx.mjs";
12
+ export { extractExportedFunctionBody, extractExportedFunctions, extractExportedMethodBodies, readBalancedBlock } from "./exports.mjs";
13
+ export { extractHttpRequests, extractPlaywrightVisitedRoutes, extractRouteLiteral } from "./requests.mjs";
14
+ export { createSourceFile, extractLineNumber, extractStringLiteral } from "./syntax.mjs";
@@ -0,0 +1,69 @@
1
+ import ts from "typescript";
2
+
3
+ export function walkJsx(sourceFile, visitor) {
4
+ const visit = (node, formContext = null) => {
5
+ let nextFormContext = formContext;
6
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
7
+ nextFormContext = visitor(node, formContext);
8
+ }
9
+
10
+ if (ts.isJsxElement(node)) {
11
+ for (const child of node.children) visit(child, nextFormContext);
12
+ return;
13
+ }
14
+
15
+ ts.forEachChild(node, (child) => visit(child, nextFormContext));
16
+ };
17
+
18
+ visit(sourceFile, null);
19
+ }
20
+
21
+ export function collectJsxAttributes(openingElement) {
22
+ const attributes = {};
23
+ for (const property of openingElement.attributes.properties) {
24
+ if (!ts.isJsxAttribute(property)) continue;
25
+ const name = property.name.text;
26
+ if (!property.initializer) {
27
+ attributes[name] = { stringValue: "true", expression: null };
28
+ continue;
29
+ }
30
+ if (ts.isStringLiteral(property.initializer)) {
31
+ attributes[name] = { stringValue: property.initializer.text, expression: null };
32
+ continue;
33
+ }
34
+ if (ts.isJsxExpression(property.initializer)) {
35
+ attributes[name] = {
36
+ stringValue: property.initializer.expression && ts.isStringLiteralLike(property.initializer.expression)
37
+ ? property.initializer.expression.text
38
+ : null,
39
+ expression: property.initializer.expression || null,
40
+ };
41
+ }
42
+ }
43
+ return attributes;
44
+ }
45
+
46
+ export function flattenJsxText(children) {
47
+ let text = "";
48
+ for (const child of children) {
49
+ if (ts.isJsxText(child)) {
50
+ text += child.getText().replace(/\s+/gu, " ");
51
+ } else if (ts.isJsxExpression(child) && child.expression && ts.isStringLiteralLike(child.expression)) {
52
+ text += child.expression.text;
53
+ } else if (ts.isJsxElement(child)) {
54
+ text += flattenJsxText(child.children);
55
+ }
56
+ }
57
+ return text;
58
+ }
59
+
60
+ export function extractJsxLabel(node, attributes) {
61
+ const ariaLabel = attributes["aria-label"]?.stringValue || attributes.title?.stringValue || null;
62
+ if (ariaLabel) return ariaLabel;
63
+ if (ts.isJsxElement(node)) {
64
+ const text = flattenJsxText(node.children).trim();
65
+ if (text) return text;
66
+ }
67
+ return null;
68
+ }
69
+
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { collectJsxAttributes, createSourceFile, extractJsxLabel, walkJsx } from "./index.mjs";
3
+
4
+ describe("@elench/ts-analysis JSX helpers", () => {
5
+ it("walks JSX and extracts attributes and labels", () => {
6
+ const sourceFile = createSourceFile(
7
+ "page.tsx",
8
+ `
9
+ export default function Page() {
10
+ return (
11
+ <form action={submit}>
12
+ <button data-testid="save-button" aria-label="Save settings">Save</button>
13
+ </form>
14
+ );
15
+ }
16
+ `
17
+ );
18
+ const tags = [];
19
+
20
+ walkJsx(sourceFile, (node) => {
21
+ const opening = "openingElement" in node ? node.openingElement : node;
22
+ const attributes = collectJsxAttributes(opening);
23
+ tags.push({
24
+ tag: opening.tagName.getText(),
25
+ label: extractJsxLabel(node, attributes),
26
+ attributes,
27
+ });
28
+ return null;
29
+ });
30
+
31
+ expect(tags).toHaveLength(2);
32
+ expect(tags[0].tag).toBe("form");
33
+ expect(tags[0].attributes.action.stringValue).toBeNull();
34
+ expect(tags[0].attributes.action.expression.getText()).toBe("submit");
35
+ expect(tags[1]).toMatchObject({
36
+ tag: "button",
37
+ label: "Save settings",
38
+ attributes: {
39
+ "data-testid": { stringValue: "save-button", expression: null },
40
+ },
41
+ });
42
+ });
43
+ });
@@ -0,0 +1,100 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import ts from "typescript";
4
+ import { defaultCompilerOptions, normalizePath } from "./shared.mjs";
5
+ import { resolveImportToSourceFile } from "./resolution.mjs";
6
+
7
+ export function findNearestTsConfig(startDir) {
8
+ return ts.findConfigFile(startDir, ts.sys.fileExists, "tsconfig.json") || null;
9
+ }
10
+
11
+ export function loadTsConfig(tsconfigPath) {
12
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
13
+ if (configFile.error) {
14
+ return {
15
+ tsconfigPath,
16
+ rootDir: path.dirname(tsconfigPath),
17
+ fileNames: [],
18
+ options: defaultCompilerOptions(path.dirname(tsconfigPath)),
19
+ errors: [configFile.error],
20
+ };
21
+ }
22
+
23
+ const parsed = ts.parseJsonConfigFileContent(
24
+ configFile.config,
25
+ ts.sys,
26
+ path.dirname(tsconfigPath),
27
+ defaultCompilerOptions(path.dirname(tsconfigPath)),
28
+ tsconfigPath
29
+ );
30
+
31
+ return {
32
+ tsconfigPath,
33
+ rootDir: path.dirname(tsconfigPath),
34
+ fileNames: parsed.fileNames.map(normalizePath),
35
+ options: parsed.options,
36
+ errors: parsed.errors || [],
37
+ };
38
+ }
39
+
40
+ export function createAnalysisProject({ rootDir, tsconfigPath = null } = {}) {
41
+ const resolvedRoot = path.resolve(rootDir || process.cwd());
42
+ const resolvedConfigPath = tsconfigPath ? path.resolve(tsconfigPath) : findNearestTsConfig(resolvedRoot);
43
+ const config = resolvedConfigPath
44
+ ? loadTsConfig(resolvedConfigPath)
45
+ : {
46
+ tsconfigPath: null,
47
+ rootDir: resolvedRoot,
48
+ fileNames: listProjectFiles(resolvedRoot),
49
+ options: defaultCompilerOptions(resolvedRoot),
50
+ errors: [],
51
+ };
52
+
53
+ const program = ts.createProgram(config.fileNames, config.options);
54
+ const checker = program.getTypeChecker();
55
+ const diagnostics = [...config.errors, ...ts.getPreEmitDiagnostics(program)];
56
+
57
+ return {
58
+ rootDir: config.rootDir,
59
+ tsconfigPath: config.tsconfigPath,
60
+ program,
61
+ checker,
62
+ compilerOptions: config.options,
63
+ diagnostics,
64
+ getSourceFiles() {
65
+ return program.getSourceFiles().filter((file) => !file.isDeclarationFile);
66
+ },
67
+ getSourceFile(filePath) {
68
+ return program.getSourceFile(path.isAbsolute(filePath) ? filePath : path.join(config.rootDir, filePath)) || null;
69
+ },
70
+ readSourceFile(filePath) {
71
+ const sourceFile = this.getSourceFile(filePath);
72
+ return sourceFile ? sourceFile.text : null;
73
+ },
74
+ resolveImport(fromFilePath, specifier) {
75
+ return resolveImportToSourceFile(config.rootDir, normalizePath(fromFilePath), specifier, config.options);
76
+ },
77
+ };
78
+ }
79
+
80
+ function listProjectFiles(rootDir) {
81
+ const results = [];
82
+ const queue = [rootDir];
83
+ while (queue.length > 0) {
84
+ const current = queue.pop();
85
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
86
+ if (entry.isSymbolicLink()) continue;
87
+ const absolutePath = path.join(current, entry.name);
88
+ if (entry.isDirectory()) {
89
+ if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") continue;
90
+ queue.push(absolutePath);
91
+ continue;
92
+ }
93
+ if (/\.(ts|tsx|js|jsx|mjs)$/u.test(entry.name)) {
94
+ results.push(normalizePath(absolutePath));
95
+ }
96
+ }
97
+ }
98
+ return results.sort((left, right) => left.localeCompare(right));
99
+ }
100
+
@@ -0,0 +1,54 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { createAnalysisProject, findNearestTsConfig, loadTsConfig } from "./index.mjs";
6
+
7
+ const cleanups = [];
8
+
9
+ afterEach(() => {
10
+ while (cleanups.length > 0) {
11
+ cleanups.pop()();
12
+ }
13
+ });
14
+
15
+ describe("@elench/ts-analysis project", () => {
16
+ it("loads tsconfig-backed projects and resolves imports", () => {
17
+ const rootDir = createProject({
18
+ "tsconfig.json": JSON.stringify({
19
+ compilerOptions: {
20
+ baseUrl: ".",
21
+ paths: {
22
+ "@/*": ["src/*"],
23
+ },
24
+ jsx: "preserve",
25
+ module: "esnext",
26
+ target: "es2022",
27
+ },
28
+ include: ["src/**/*"],
29
+ }),
30
+ "src/app/page.tsx": `import { save } from "@/server/actions"; export default function Page() { return <button onClick={save}>Save</button>; }`,
31
+ "src/server/actions.ts": `export async function save() { return true; }`,
32
+ });
33
+
34
+ const tsconfigPath = findNearestTsConfig(path.join(rootDir, "src", "app"));
35
+ const config = loadTsConfig(tsconfigPath);
36
+ const project = createAnalysisProject({ rootDir });
37
+
38
+ expect(config.fileNames.some((filePath) => filePath.endsWith("src/app/page.tsx"))).toBe(true);
39
+ expect(project.resolveImport("src/app/page.tsx", "@/server/actions")).toBe("src/server/actions.ts");
40
+ expect(project.readSourceFile("src/server/actions.ts")).toContain("export async function save");
41
+ expect(project.diagnostics).toEqual([]);
42
+ });
43
+ });
44
+
45
+ function createProject(files) {
46
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-ts-analysis-"));
47
+ cleanups.push(() => fs.rmSync(rootDir, { recursive: true, force: true }));
48
+ for (const [relativePath, content] of Object.entries(files)) {
49
+ const absolutePath = path.join(rootDir, relativePath);
50
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
51
+ fs.writeFileSync(absolutePath, content);
52
+ }
53
+ return rootDir;
54
+ }