@anaemia/bundler 0.4.0 → 0.5.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/dist/analyzer/ast-utils.d.ts +5 -0
- package/dist/analyzer/ast-utils.d.ts.map +1 -0
- package/dist/analyzer/ast-utils.js +16 -0
- package/dist/analyzer/ast-walker.d.ts +15 -0
- package/dist/analyzer/ast-walker.d.ts.map +1 -0
- package/dist/analyzer/ast-walker.js +43 -0
- package/dist/analyzer/checks/env-access.d.ts +3 -0
- package/dist/analyzer/checks/env-access.d.ts.map +1 -0
- package/dist/analyzer/checks/env-access.js +63 -0
- package/dist/analyzer/checks/route-metadata.d.ts +12 -0
- package/dist/analyzer/checks/route-metadata.d.ts.map +1 -0
- package/dist/analyzer/checks/route-metadata.js +74 -0
- package/dist/analyzer/checks/server-functions.d.ts +3 -0
- package/dist/analyzer/checks/server-functions.d.ts.map +1 -0
- package/dist/analyzer/checks/server-functions.js +75 -0
- package/dist/analyzer/index.d.ts +7 -0
- package/dist/analyzer/index.d.ts.map +1 -0
- package/dist/analyzer/index.js +49 -0
- package/dist/analyzer/parser.d.ts +4 -0
- package/dist/analyzer/parser.d.ts.map +1 -0
- package/dist/analyzer/parser.js +96 -0
- package/dist/analyzer/types.d.ts +48 -0
- package/dist/analyzer/types.d.ts.map +1 -0
- package/dist/analyzer/types.js +1 -0
- package/dist/env-loader.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -1
- package/dist/router/manifest.d.ts +3 -2
- package/dist/router/manifest.d.ts.map +1 -1
- package/dist/router/manifest.js +18 -18
- package/package.json +8 -2
- package/src/analyzer/ast-utils.ts +22 -0
- package/src/analyzer/ast-walker.ts +63 -0
- package/src/analyzer/checks/env-access.ts +77 -0
- package/src/analyzer/checks/route-metadata.ts +91 -0
- package/src/analyzer/checks/server-functions.ts +85 -0
- package/src/analyzer/index.ts +70 -0
- package/src/analyzer/parser.ts +103 -0
- package/src/analyzer/types.ts +55 -0
- package/src/env-loader.ts +1 -1
- package/src/index.ts +43 -1
- package/src/router/manifest.ts +21 -30
- package/test/analyzer.test.mjs +308 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
import type { RouteManifestEntry } from "
|
|
2
|
-
|
|
1
|
+
import type { RouteManifestEntry } from "../router/scan.js";
|
|
2
|
+
import type { RouteMetadata } from "../analyzer/checks/route-metadata.js";
|
|
3
|
+
export declare function writeManifest(appRoot: string, routes: RouteManifestEntry[], routeMetadata: RouteMetadata[]): void;
|
|
3
4
|
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/router/manifest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/router/manifest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sCAAsC,CAAC;AAE1E,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,aAAa,EAAE,aAAa,EAAE,QAsB1G"}
|
package/dist/router/manifest.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
export function writeManifest(appRoot, routes) {
|
|
4
|
-
const
|
|
5
|
-
for (const route of routes) {
|
|
6
|
-
if (route.filePath.endsWith("404.tsx")) {
|
|
7
|
-
errors["404"] = route.urlPattern;
|
|
8
|
-
}
|
|
9
|
-
if (route.filePath.endsWith("500.tsx")) {
|
|
10
|
-
errors["500"] = route.urlPattern;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
const conventionalRoutes = routes.filter((r) => !r.filePath.endsWith("404.tsx") && !r.filePath.endsWith("500.tsx"));
|
|
3
|
+
export function writeManifest(appRoot, routes, routeMetadata) {
|
|
4
|
+
const metadataMap = new Map(routeMetadata.map((m) => [path.resolve(appRoot, m.filePath), m]));
|
|
14
5
|
const manifest = {
|
|
15
|
-
routes:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
6
|
+
routes: routes.map((route) => {
|
|
7
|
+
const meta = metadataMap.get(route.filePath);
|
|
8
|
+
return {
|
|
9
|
+
...route,
|
|
10
|
+
isStatic: meta?.isStatic ?? false,
|
|
11
|
+
hasLoader: meta?.hasLoader ?? false,
|
|
12
|
+
hasGuard: meta?.hasGuard ?? false,
|
|
13
|
+
serverFunctionIds: meta?.serverFunctionIds ?? [],
|
|
14
|
+
};
|
|
15
|
+
}),
|
|
16
|
+
chunks: {},
|
|
19
17
|
};
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
fs.
|
|
18
|
+
const manifestPath = path.resolve(appRoot, "./dist/route-manifest.json");
|
|
19
|
+
const manifestDir = path.dirname(manifestPath);
|
|
20
|
+
if (!fs.existsSync(manifestDir))
|
|
21
|
+
fs.mkdirSync(manifestDir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
23
23
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anaemia/bundler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
".": {
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
10
|
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./analyzer": {
|
|
13
|
+
"types": "./dist/analyzer/index.d.ts",
|
|
14
|
+
"default": "./dist/analyzer/index.js"
|
|
11
15
|
}
|
|
12
16
|
},
|
|
13
17
|
"dependencies": {
|
|
14
|
-
"@anaemia/core": "^0.
|
|
18
|
+
"@anaemia/core": "^0.5.0",
|
|
15
19
|
"@babel/core": "^7.29.7",
|
|
16
20
|
"@babel/preset-typescript": "^7.29.7",
|
|
17
21
|
"@rspack/core": "^2.0.5",
|
|
@@ -21,6 +25,8 @@
|
|
|
21
25
|
"dotenv-expand": "^13.0.0",
|
|
22
26
|
"glob": "^13.0.6",
|
|
23
27
|
"jiti": "^2.7.0",
|
|
28
|
+
"oxc-parser": "^0.133.0",
|
|
29
|
+
"picocolors": "^1.1.1",
|
|
24
30
|
"sass": "^1.100.0",
|
|
25
31
|
"sass-loader": "^17.0.0",
|
|
26
32
|
"solid-refresh": "^0.7.8",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AstNode } from "./ast-walker.js";
|
|
2
|
+
|
|
3
|
+
export function prop<T = unknown>(node: AstNode, key: string): T {
|
|
4
|
+
return (node as Record<string, unknown>)[key] as T;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function child(node: AstNode, key: string): AstNode | null {
|
|
8
|
+
const value = (node as Record<string, unknown>)[key];
|
|
9
|
+
if (value && typeof value === "object" && typeof (value as AstNode).type === "string") {
|
|
10
|
+
return value as AstNode;
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function children(node: AstNode, key: string): AstNode[] {
|
|
16
|
+
const value = (node as Record<string, unknown>)[key];
|
|
17
|
+
if (!Array.isArray(value)) return [];
|
|
18
|
+
return value.filter(
|
|
19
|
+
(v): v is AstNode =>
|
|
20
|
+
v !== null && v !== undefined && typeof v === "object" && typeof (v as Record<string, unknown>).type === "string",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { visitorKeys } from "oxc-parser";
|
|
2
|
+
import type { Program } from "oxc-parser";
|
|
3
|
+
|
|
4
|
+
export type AstNode = {
|
|
5
|
+
type: string;
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type WalkController = {
|
|
10
|
+
skip: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AstWalker = {
|
|
14
|
+
enter?: (node: AstNode, parent: AstNode | null, controller: WalkController) => void;
|
|
15
|
+
leave?: (node: AstNode, parent: AstNode | null) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function isAstNode(value: unknown): value is AstNode {
|
|
19
|
+
return Boolean(value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function childKeysForNode(node: AstNode): string[] {
|
|
23
|
+
const keysByNodeType = visitorKeys as Record<string, string[] | undefined>;
|
|
24
|
+
const configuredKeys = keysByNodeType[node.type];
|
|
25
|
+
if (configuredKeys) return configuredKeys;
|
|
26
|
+
|
|
27
|
+
return Object.keys(node).filter((key) => {
|
|
28
|
+
if (key === "type" || key === "start" || key === "end" || key === "range" || key === "loc") return false;
|
|
29
|
+
const value = node[key];
|
|
30
|
+
return isAstNode(value) || (Array.isArray(value) && value.some(isAstNode));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function walkAst(root: AstNode | Program, walker: AstWalker): void {
|
|
35
|
+
const visit = (node: AstNode, parent: AstNode | null) => {
|
|
36
|
+
const state = { skipped: false };
|
|
37
|
+
const controller: WalkController = {
|
|
38
|
+
skip() {
|
|
39
|
+
state.skipped = true;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
walker.enter?.(node, parent, controller);
|
|
44
|
+
|
|
45
|
+
if (!state.skipped) {
|
|
46
|
+
for (const key of childKeysForNode(node)) {
|
|
47
|
+
const value = node[key];
|
|
48
|
+
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
for (const child of value) {
|
|
51
|
+
if (isAstNode(child)) visit(child, node);
|
|
52
|
+
}
|
|
53
|
+
} else if (isAstNode(value)) {
|
|
54
|
+
visit(value, node);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
walker.leave?.(node, parent);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
visit(root as AstNode, null);
|
|
63
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { walkAst } from "../ast-walker.js";
|
|
2
|
+
import { prop, child } from "../ast-utils.js";
|
|
3
|
+
import type { AstNode } from "../ast-walker.js";
|
|
4
|
+
import type { AnalyzerDiagnostic, ParsedAnalyzerFile } from "../types.js";
|
|
5
|
+
|
|
6
|
+
const SERVER_FILE_PATTERNS = [/\.server\.(ts|tsx|js|jsx)$/, /server\//, /\/api\//, /_route\.(ts|tsx|js|jsx)$/];
|
|
7
|
+
|
|
8
|
+
function isClientFile(filePath: string): boolean {
|
|
9
|
+
return !SERVER_FILE_PATTERNS.some((p) => p.test(filePath));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ALWAYS_SAFE = new Set(["NODE_ENV", "MODE", "DEV", "PROD"]);
|
|
13
|
+
|
|
14
|
+
function startLine(node: AstNode): number | undefined {
|
|
15
|
+
const loc = prop<{ start: { line: number } } | undefined>(node, "loc");
|
|
16
|
+
return loc?.start.line;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function checkEnvAccess(file: ParsedAnalyzerFile): AnalyzerDiagnostic[] {
|
|
20
|
+
if (!file.program || !isClientFile(file.relativePath)) return [];
|
|
21
|
+
|
|
22
|
+
const diagnostics: AnalyzerDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
walkAst(file.program, {
|
|
25
|
+
enter(node: AstNode) {
|
|
26
|
+
if (node.type !== "MemberExpression") return;
|
|
27
|
+
|
|
28
|
+
const obj = child(node, "object");
|
|
29
|
+
const propNode = child(node, "property");
|
|
30
|
+
if (!obj || !propNode) return;
|
|
31
|
+
|
|
32
|
+
// process.env
|
|
33
|
+
if (
|
|
34
|
+
obj.type === "Identifier" &&
|
|
35
|
+
prop<string>(obj, "name") === "process" &&
|
|
36
|
+
propNode.type === "Identifier" &&
|
|
37
|
+
prop<string>(propNode, "name") === "env"
|
|
38
|
+
) {
|
|
39
|
+
diagnostics.push({
|
|
40
|
+
code: "PROCESS_ENV_ACCESS",
|
|
41
|
+
severity: "warning",
|
|
42
|
+
message: "using process.env is not recommended in anaemia apps. Use import.meta.env instead.",
|
|
43
|
+
filePath: file.relativePath,
|
|
44
|
+
line: startLine(node),
|
|
45
|
+
help: "replace process.env with import.meta.env and ensure client variables are prefixed with PUBLIC_",
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// import.meta.env.KEY
|
|
51
|
+
if (obj.type === "MemberExpression") {
|
|
52
|
+
const innerObj = child(obj, "object");
|
|
53
|
+
const innerProp = child(obj, "property");
|
|
54
|
+
if (
|
|
55
|
+
innerObj?.type === "MetaProperty" &&
|
|
56
|
+
innerProp?.type === "Identifier" &&
|
|
57
|
+
prop<string>(innerProp, "name") === "env" &&
|
|
58
|
+
propNode.type === "Identifier"
|
|
59
|
+
) {
|
|
60
|
+
const envKey = prop<string>(propNode, "name");
|
|
61
|
+
if (!envKey.startsWith("PUBLIC_") && !ALWAYS_SAFE.has(envKey)) {
|
|
62
|
+
diagnostics.push({
|
|
63
|
+
code: "ENV_NOT_PUBLIC",
|
|
64
|
+
severity: "warning",
|
|
65
|
+
message: `import.meta.env.${envKey} is not prefixed with PUBLIC_ and will be undefined on the client`,
|
|
66
|
+
filePath: file.relativePath,
|
|
67
|
+
line: startLine(node),
|
|
68
|
+
help: `rename to PUBLIC_${envKey} or move this code to a .server.ts file`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return diagnostics;
|
|
77
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { walkAst } from "../ast-walker.js";
|
|
2
|
+
import { prop, child, children } from "../ast-utils.js";
|
|
3
|
+
import type { AstNode } from "../ast-walker.js";
|
|
4
|
+
import type { ParsedAnalyzerFile } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export interface RouteMetadata {
|
|
7
|
+
filePath: string;
|
|
8
|
+
hasServerFunctions: boolean;
|
|
9
|
+
hasLoader: boolean;
|
|
10
|
+
hasGuard: boolean;
|
|
11
|
+
isStatic: boolean;
|
|
12
|
+
serverFunctionIds: string[];
|
|
13
|
+
params: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function extractRouteMetadata(file: ParsedAnalyzerFile): RouteMetadata {
|
|
17
|
+
if (!file.program) {
|
|
18
|
+
return {
|
|
19
|
+
filePath: file.relativePath,
|
|
20
|
+
hasServerFunctions: false,
|
|
21
|
+
hasLoader: false,
|
|
22
|
+
hasGuard: false,
|
|
23
|
+
isStatic: true,
|
|
24
|
+
serverFunctionIds: [],
|
|
25
|
+
params: extractParamsFromPath(file.relativePath),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let hasServerFunctions = false;
|
|
30
|
+
let hasLoader = false;
|
|
31
|
+
let hasGuard = false;
|
|
32
|
+
const serverFunctionIds: string[] = [];
|
|
33
|
+
|
|
34
|
+
walkAst(file.program, {
|
|
35
|
+
enter(node: AstNode) {
|
|
36
|
+
if (node.type === "ImportDeclaration") {
|
|
37
|
+
const source = child(node, "source");
|
|
38
|
+
if (source && /\.server/.test(prop<string>(source, "value"))) {
|
|
39
|
+
hasServerFunctions = true;
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (node.type === "ExportNamedDeclaration") {
|
|
45
|
+
const decl = child(node, "declaration");
|
|
46
|
+
if (decl?.type === "VariableDeclaration") {
|
|
47
|
+
for (const d of children(decl, "declarations")) {
|
|
48
|
+
const id = child(d, "id");
|
|
49
|
+
if (!id) continue;
|
|
50
|
+
const name = prop<string>(id, "name");
|
|
51
|
+
if (name === "loader") hasLoader = true;
|
|
52
|
+
if (name === "guard") hasGuard = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (node.type === "CallExpression") {
|
|
59
|
+
const callee = child(node, "callee");
|
|
60
|
+
const args = children(node, "arguments");
|
|
61
|
+
const idArg = args[1];
|
|
62
|
+
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
64
|
+
if (!idArg) return;
|
|
65
|
+
if (
|
|
66
|
+
callee?.type === "Identifier" &&
|
|
67
|
+
prop<string>(callee, "name") === "runOnServer" &&
|
|
68
|
+
idArg.type === "Literal"
|
|
69
|
+
) {
|
|
70
|
+
const value = prop<unknown>(idArg, "value");
|
|
71
|
+
if (typeof value === "string") serverFunctionIds.push(value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
filePath: file.relativePath,
|
|
79
|
+
hasServerFunctions,
|
|
80
|
+
hasLoader,
|
|
81
|
+
hasGuard,
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
83
|
+
isStatic: !hasServerFunctions && !hasLoader && !hasGuard,
|
|
84
|
+
serverFunctionIds,
|
|
85
|
+
params: extractParamsFromPath(file.relativePath),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractParamsFromPath(filePath: string): string[] {
|
|
90
|
+
return [...filePath.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]);
|
|
91
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { walkAst } from "../ast-walker.js";
|
|
2
|
+
import { prop, child, children } from "../ast-utils.js";
|
|
3
|
+
import type { AstNode } from "../ast-walker.js";
|
|
4
|
+
import type { AnalyzerDiagnostic, ParsedAnalyzerFile } from "../types.js";
|
|
5
|
+
|
|
6
|
+
function collectServerFunctionDefinitions(
|
|
7
|
+
files: ParsedAnalyzerFile[],
|
|
8
|
+
): Map<string, { filePath: string; line?: number }> {
|
|
9
|
+
const definitions = new Map<string, { filePath: string; line?: number }>();
|
|
10
|
+
|
|
11
|
+
for (const file of files) {
|
|
12
|
+
if (!file.program) continue;
|
|
13
|
+
walkAst(file.program, {
|
|
14
|
+
enter(node: AstNode) {
|
|
15
|
+
if (node.type !== "CallExpression") return;
|
|
16
|
+
const callee = child(node, "callee");
|
|
17
|
+
const args = children(node, "arguments");
|
|
18
|
+
const idArg = args[1];
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
21
|
+
if (!idArg) return;
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
callee?.type === "Identifier" &&
|
|
25
|
+
prop<string>(callee, "name") === "runOnServer" &&
|
|
26
|
+
idArg.type === "Literal"
|
|
27
|
+
) {
|
|
28
|
+
const value = prop<unknown>(idArg, "value");
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
definitions.set(value, {
|
|
31
|
+
filePath: file.relativePath,
|
|
32
|
+
line: prop<{ start: { line: number } } | undefined>(node, "loc")?.start.line,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return definitions;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function collectServerFunctionImports(files: ParsedAnalyzerFile[]): Set<string> {
|
|
44
|
+
const imports = new Set<string>();
|
|
45
|
+
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
if (!file.program) continue;
|
|
48
|
+
walkAst(file.program, {
|
|
49
|
+
enter(node: AstNode) {
|
|
50
|
+
if (node.type !== "ImportDeclaration") return;
|
|
51
|
+
const source = child(node, "source");
|
|
52
|
+
if (!source || !/\.server/.test(prop<string>(source, "value"))) return;
|
|
53
|
+
for (const specifier of children(node, "specifiers")) {
|
|
54
|
+
if (specifier.type === "ImportSpecifier") {
|
|
55
|
+
const local = child(specifier, "local");
|
|
56
|
+
if (local) imports.add(prop<string>(local, "name"));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return imports;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function checkUnusedServerFunctions(files: ParsedAnalyzerFile[]): AnalyzerDiagnostic[] {
|
|
67
|
+
const definitions = collectServerFunctionDefinitions(files);
|
|
68
|
+
const imports = collectServerFunctionImports(files);
|
|
69
|
+
const diagnostics: AnalyzerDiagnostic[] = [];
|
|
70
|
+
|
|
71
|
+
for (const [id, { filePath, line }] of definitions) {
|
|
72
|
+
if (!imports.has(id)) {
|
|
73
|
+
diagnostics.push({
|
|
74
|
+
code: "UNUSED_SERVER_FUNCTION",
|
|
75
|
+
severity: "info",
|
|
76
|
+
message: `server function "${id}" is defined but never imported`,
|
|
77
|
+
filePath,
|
|
78
|
+
line,
|
|
79
|
+
help: `remove it or check if it's being imported under a different name`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return diagnostics;
|
|
85
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
import { parseAnalyzerFile } from "./parser.js";
|
|
5
|
+
import type { AnalyzeAppOptions, AnalyzerResult } from "./types.js";
|
|
6
|
+
|
|
7
|
+
import { checkUnusedServerFunctions } from "./checks/server-functions.js";
|
|
8
|
+
import { extractRouteMetadata } from "./checks/route-metadata.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_ANALYZER_PATTERNS = [
|
|
11
|
+
"anaemia.config.{ts,js,mjs,cjs}",
|
|
12
|
+
"src/root.{tsx,jsx}",
|
|
13
|
+
"src/routes/**/*.{ts,tsx,js,jsx}",
|
|
14
|
+
"src/**/*.{ts,tsx,js,jsx}",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function uniqueFilePaths(filePaths: string[]): string[] {
|
|
18
|
+
return [...new Set(filePaths.map((filePath) => path.resolve(filePath)))].sort();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function collectAnalyzerFiles(appRoot: string, include = DEFAULT_ANALYZER_PATTERNS): string[] {
|
|
22
|
+
const files = include.flatMap((pattern) =>
|
|
23
|
+
glob.sync(pattern, {
|
|
24
|
+
cwd: appRoot,
|
|
25
|
+
absolute: true,
|
|
26
|
+
nodir: true,
|
|
27
|
+
posix: true,
|
|
28
|
+
ignore: ["node_modules/**", "dist/**", ".anaemia/**"],
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return uniqueFilePaths(files).filter((filePath) => fs.existsSync(filePath));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function analyzeApp(appRoot: string, options: AnalyzeAppOptions = {}): Promise<AnalyzerResult> {
|
|
36
|
+
const normalizedRoot = path.resolve(appRoot);
|
|
37
|
+
const files = collectAnalyzerFiles(normalizedRoot, options.include);
|
|
38
|
+
const parsedFiles = files.map((filePath) => parseAnalyzerFile(normalizedRoot, filePath));
|
|
39
|
+
|
|
40
|
+
// per-file diagnostics
|
|
41
|
+
const diagnostics = parsedFiles.flatMap((file) => file.diagnostics);
|
|
42
|
+
|
|
43
|
+
// cross-file diagnostics
|
|
44
|
+
const unusedServerFnDiagnostics = checkUnusedServerFunctions(parsedFiles);
|
|
45
|
+
|
|
46
|
+
// route metadata for manifest
|
|
47
|
+
const routeFiles = parsedFiles.filter((f) => /src\/routes\//.test(f.filePath));
|
|
48
|
+
const routeMetadata = routeFiles.map(extractRouteMetadata);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
appRoot: normalizedRoot,
|
|
52
|
+
build: {
|
|
53
|
+
mode: options.mode ?? process.env.NODE_ENV ?? "development",
|
|
54
|
+
analyzedAt: new Date().toISOString(),
|
|
55
|
+
},
|
|
56
|
+
files: parsedFiles,
|
|
57
|
+
diagnostics: [...diagnostics, ...unusedServerFnDiagnostics],
|
|
58
|
+
routeMetadata,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { parseAnalyzerFile } from "./parser.js";
|
|
63
|
+
export { walkAst } from "./ast-walker.js";
|
|
64
|
+
export type {
|
|
65
|
+
AnalyzeAppOptions,
|
|
66
|
+
AnalyzerDiagnostic,
|
|
67
|
+
AnalyzerFileKind,
|
|
68
|
+
AnalyzerResult,
|
|
69
|
+
ParsedAnalyzerFile,
|
|
70
|
+
} from "./types.js";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseSync } from "oxc-parser";
|
|
4
|
+
import type { ParserOptions } from "oxc-parser";
|
|
5
|
+
import type { AnalyzerDiagnostic, AnalyzerFileKind, ParsedAnalyzerFile, ParserError, SourceLocation } from "./types.js";
|
|
6
|
+
import { checkEnvAccess } from "./checks/env-access.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PARSE_OPTIONS: ParserOptions = {
|
|
9
|
+
sourceType: "module",
|
|
10
|
+
astType: "ts",
|
|
11
|
+
range: true,
|
|
12
|
+
preserveParens: false,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function offsetToLocation(source: string, offset: number): SourceLocation {
|
|
16
|
+
const boundedOffset = Math.max(0, Math.min(offset, source.length));
|
|
17
|
+
let line = 1;
|
|
18
|
+
let column = 1;
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < boundedOffset; i++) {
|
|
21
|
+
if (source.charCodeAt(i) === 10) {
|
|
22
|
+
line++;
|
|
23
|
+
column = 1;
|
|
24
|
+
} else {
|
|
25
|
+
column++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { line, column };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function severityFromOxc(error: ParserError): AnalyzerDiagnostic["severity"] {
|
|
33
|
+
if (error.severity === "Error") return "error";
|
|
34
|
+
if (error.severity === "Warning") return "warning";
|
|
35
|
+
return "info";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function diagnosticFromOxcError(filePath: string, source: string, error: ParserError): AnalyzerDiagnostic {
|
|
39
|
+
const label = error.labels[0];
|
|
40
|
+
const start = label.start;
|
|
41
|
+
const location = typeof start === "number" ? offsetToLocation(source, start) : undefined;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
code: "parse",
|
|
45
|
+
severity: severityFromOxc(error),
|
|
46
|
+
message: label.message ? `${error.message}: ${label.message}` : error.message,
|
|
47
|
+
filePath,
|
|
48
|
+
start,
|
|
49
|
+
end: label.end,
|
|
50
|
+
line: location?.line,
|
|
51
|
+
column: location?.column,
|
|
52
|
+
help: error.helpMessage ?? undefined,
|
|
53
|
+
codeframe: error.codeframe ?? undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function inferAnalyzerFileKind(appRoot: string, filePath: string): AnalyzerFileKind {
|
|
58
|
+
const relativePath = path.relative(appRoot, filePath).replace(/\\/g, "/");
|
|
59
|
+
|
|
60
|
+
if (/^anaemia\.config\.[cm]?[jt]s$/.test(relativePath)) return "config";
|
|
61
|
+
if (relativePath === "src/root.tsx" || relativePath === "src/root.jsx") return "root";
|
|
62
|
+
if (/^src\/routes\/.*\/?_route\.[jt]sx?$/.test(relativePath)) return "server-route";
|
|
63
|
+
if (/^src\/routes\/.*\.[jt]sx$/.test(relativePath)) return "route";
|
|
64
|
+
return "source";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseAnalyzerFile(appRoot: string, filePath: string, options: ParserOptions = {}): ParsedAnalyzerFile {
|
|
68
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
69
|
+
const relativePath = path.relative(appRoot, filePath).replace(/\\/g, "/");
|
|
70
|
+
const kind = inferAnalyzerFileKind(appRoot, filePath);
|
|
71
|
+
const parseOptions = { ...DEFAULT_PARSE_OPTIONS, ...options };
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const result = parseSync(filePath, source, parseOptions);
|
|
75
|
+
const parseDiagnostics = result.errors.map((error) => diagnosticFromOxcError(filePath, source, error));
|
|
76
|
+
const file: ParsedAnalyzerFile = {
|
|
77
|
+
filePath,
|
|
78
|
+
relativePath,
|
|
79
|
+
kind,
|
|
80
|
+
source,
|
|
81
|
+
program: result.program,
|
|
82
|
+
comments: result.comments,
|
|
83
|
+
module: result.module,
|
|
84
|
+
diagnostics: [],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const diagnostics = [...parseDiagnostics, ...checkEnvAccess(file)];
|
|
88
|
+
|
|
89
|
+
return { ...file, diagnostics };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
return {
|
|
93
|
+
filePath,
|
|
94
|
+
relativePath,
|
|
95
|
+
kind,
|
|
96
|
+
source,
|
|
97
|
+
program: null,
|
|
98
|
+
comments: [],
|
|
99
|
+
module: null,
|
|
100
|
+
diagnostics: [{ code: "parse:thrown", severity: "error", message, filePath }],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Comment, EcmaScriptModule, OxcError, Program } from "oxc-parser";
|
|
2
|
+
import type { RouteMetadata } from "./checks/route-metadata.js";
|
|
3
|
+
|
|
4
|
+
type AnalyzerSeverity = "error" | "warning" | "info";
|
|
5
|
+
|
|
6
|
+
export type AnalyzerDiagnostic = {
|
|
7
|
+
code: string;
|
|
8
|
+
severity: AnalyzerSeverity;
|
|
9
|
+
message: string;
|
|
10
|
+
filePath: string;
|
|
11
|
+
start?: number;
|
|
12
|
+
end?: number;
|
|
13
|
+
line?: number;
|
|
14
|
+
column?: number;
|
|
15
|
+
help?: string;
|
|
16
|
+
codeframe?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AnalyzerFileKind = "config" | "route" | "server-route" | "root" | "source";
|
|
20
|
+
|
|
21
|
+
export type SourceLocation = {
|
|
22
|
+
line: number;
|
|
23
|
+
column: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ParsedAnalyzerFile = {
|
|
27
|
+
filePath: string;
|
|
28
|
+
relativePath: string;
|
|
29
|
+
kind: AnalyzerFileKind;
|
|
30
|
+
source: string;
|
|
31
|
+
program: Program | null;
|
|
32
|
+
comments: Comment[];
|
|
33
|
+
module: EcmaScriptModule | null;
|
|
34
|
+
diagnostics: AnalyzerDiagnostic[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AnalyzeAppOptions = {
|
|
38
|
+
mode?: string;
|
|
39
|
+
include?: string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type AnalyzerBuildInfo = {
|
|
43
|
+
mode: string;
|
|
44
|
+
analyzedAt: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type AnalyzerResult = {
|
|
48
|
+
appRoot: string;
|
|
49
|
+
build: AnalyzerBuildInfo;
|
|
50
|
+
files: ParsedAnalyzerFile[];
|
|
51
|
+
diagnostics: AnalyzerDiagnostic[];
|
|
52
|
+
routeMetadata: RouteMetadata[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ParserError = OxcError;
|
package/src/env-loader.ts
CHANGED
|
@@ -7,7 +7,7 @@ export default function loadEnvFiles(appRoot: string, mode: string) {
|
|
|
7
7
|
const files = [`.env`, `.env.local`, `.env.${mode}`, `.env.${mode}.local`];
|
|
8
8
|
|
|
9
9
|
for (const file of files) {
|
|
10
|
-
const result = loadDotenv({ path: path.resolve(appRoot, file), override: true });
|
|
10
|
+
const result = loadDotenv({ path: path.resolve(appRoot, file), override: true, quiet: true });
|
|
11
11
|
expandDotenv(result);
|
|
12
12
|
}
|
|
13
13
|
}
|