Skip to content

Commit 09a11b9

Browse files
committed
feat(observability-map): package scaffold and route scanner
1 parent c72ebf9 commit 09a11b9

8 files changed

Lines changed: 223 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@internal/observability-map",
3+
"private": true,
4+
"version": "0.0.1",
5+
"main": "./dist/src/index.js",
6+
"types": "./dist/src/index.d.ts",
7+
"dependencies": {
8+
"typescript": "catalog:"
9+
},
10+
"devDependencies": {
11+
"@types/node": "^24.13.3",
12+
"rimraf": "6.0.1"
13+
},
14+
"scripts": {
15+
"clean": "rimraf dist",
16+
"typecheck": "tsc --noEmit",
17+
"build": "pnpm run clean && tsc -p tsconfig.build.json",
18+
"test": "vitest run",
19+
"test:watch": "vitest"
20+
}
21+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import ts from "typescript";
2+
import { readdirSync, readFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import type { EntryPoint } from "./types.js";
5+
6+
function calleeName(expr: ts.Expression): string | null {
7+
if (ts.isIdentifier(expr)) return expr.text;
8+
if (ts.isPropertyAccessExpression(expr)) return expr.name.text;
9+
return null;
10+
}
11+
12+
export function scanFile(fileName: string, source: string): EntryPoint | null {
13+
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
14+
15+
const ep: EntryPoint = {
16+
fileName,
17+
source,
18+
hasLoader: false,
19+
hasAction: false,
20+
loaderInitializerCallee: null,
21+
actionInitializerCallee: null,
22+
importedNames: [],
23+
calleeNames: [],
24+
hasTryCatch: false,
25+
statementCount: 0,
26+
};
27+
28+
const isExported = (n: ts.Node) =>
29+
ts.canHaveModifiers(n) &&
30+
ts.getModifiers(n)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
31+
32+
const visit = (node: ts.Node) => {
33+
if (ts.isImportDeclaration(node) && node.importClause) {
34+
const bindings = node.importClause.namedBindings;
35+
if (bindings && ts.isNamedImports(bindings)) {
36+
for (const el of bindings.elements) ep.importedNames.push(el.name.text);
37+
}
38+
if (node.importClause.name) ep.importedNames.push(node.importClause.name.text);
39+
}
40+
41+
if (ts.isVariableStatement(node) && isExported(node)) {
42+
for (const decl of node.declarationList.declarations) {
43+
const name = decl.name.getText(sf);
44+
if (name !== "loader" && name !== "action") continue;
45+
if (name === "loader") ep.hasLoader = true;
46+
if (name === "action") ep.hasAction = true;
47+
let init = decl.initializer;
48+
if (init && ts.isPropertyAccessExpression(init)) init = init.expression;
49+
if (init && ts.isCallExpression(init)) {
50+
const cn = calleeName(init.expression);
51+
if (name === "loader") ep.loaderInitializerCallee = cn;
52+
else ep.actionInitializerCallee = cn;
53+
}
54+
}
55+
}
56+
57+
if (ts.isFunctionDeclaration(node) && node.name && isExported(node)) {
58+
if (node.name.text === "loader") ep.hasLoader = true;
59+
if (node.name.text === "action") ep.hasAction = true;
60+
if (node.body) ep.statementCount += node.body.statements.length;
61+
}
62+
63+
if (ts.isTryStatement(node)) ep.hasTryCatch = true;
64+
65+
if (ts.isCallExpression(node)) {
66+
const cn = calleeName(node.expression);
67+
if (cn) ep.calleeNames.push(cn);
68+
}
69+
70+
ts.forEachChild(node, visit);
71+
};
72+
73+
visit(sf);
74+
75+
if (!ep.hasLoader && !ep.hasAction) return null;
76+
if (ep.statementCount === 0) {
77+
ep.statementCount = countArrowBodyStatements(sf);
78+
}
79+
return ep;
80+
}
81+
82+
function countArrowBodyStatements(sf: ts.SourceFile): number {
83+
let count = 0;
84+
const visit = (n: ts.Node) => {
85+
if ((ts.isArrowFunction(n) || ts.isFunctionExpression(n)) && n.body && ts.isBlock(n.body)) {
86+
count += n.body.statements.length;
87+
}
88+
ts.forEachChild(n, visit);
89+
};
90+
visit(sf);
91+
return count;
92+
}
93+
94+
export function scanDirectory(dir: string): {
95+
entryPoints: EntryPoint[];
96+
parseFailures: string[];
97+
} {
98+
const entryPoints: EntryPoint[] = [];
99+
const parseFailures: string[] = [];
100+
const files = readdirSync(dir).filter((f) => /\.(ts|tsx)$/.test(f) && !f.endsWith(".test.ts"));
101+
102+
for (const fileName of files) {
103+
try {
104+
const ep = scanFile(fileName, readFileSync(join(dir, fileName), "utf8"));
105+
if (ep) entryPoints.push(ep);
106+
} catch {
107+
parseFailures.push(fileName);
108+
}
109+
}
110+
return { entryPoints, parseFailures };
111+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export type CheckStatus = "pass" | "fail" | "not-applicable";
2+
3+
export type CheckResult = {
4+
id: string;
5+
status: CheckStatus;
6+
detail?: string;
7+
};
8+
9+
export type EntryPoint = {
10+
fileName: string;
11+
source: string;
12+
hasLoader: boolean;
13+
hasAction: boolean;
14+
/** Callee name when `loader`/`action` is assigned from a call, e.g. a route builder. */
15+
loaderInitializerCallee: string | null;
16+
actionInitializerCallee: string | null;
17+
importedNames: string[];
18+
calleeNames: string[];
19+
hasTryCatch: boolean;
20+
/** Statement count across loader/action bodies, used by the triviality rule. */
21+
statementCount: number;
22+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { scanFile } from "../src/scan.js";
2+
3+
const LOADER = `
4+
import { json } from "@remix-run/server-runtime";
5+
export async function loader() { return json({}); }
6+
`;
7+
8+
const COMPONENT_ONLY = `
9+
export default function Page() { return null; }
10+
`;
11+
12+
describe("scanFile", () => {
13+
it("detects an exported loader as a server entry point", () => {
14+
const ep = scanFile("api.v1.things.ts", LOADER);
15+
expect(ep).not.toBeNull();
16+
expect(ep!.hasLoader).toBe(true);
17+
expect(ep!.hasAction).toBe(false);
18+
});
19+
20+
it("ignores a route that only exports a component", () => {
21+
expect(scanFile("_app.things.tsx", COMPONENT_ONLY)).toBeNull();
22+
});
23+
24+
it("detects a loader assigned from a call expression", () => {
25+
const ep = scanFile("api.v1.x.ts", `export const loader = createLoaderApiRoute({});`);
26+
expect(ep!.hasLoader).toBe(true);
27+
expect(ep!.loaderInitializerCallee).toBe("createLoaderApiRoute");
28+
});
29+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": { "noEmit": false, "outDir": "dist", "declaration": true },
4+
"exclude": ["node_modules", "dist", "test"]
5+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2019",
4+
"lib": ["ES2019"],
5+
"module": "ESNext",
6+
"moduleResolution": "Bundler",
7+
"esModuleInterop": true,
8+
"forceConsistentCasingInFileNames": true,
9+
"isolatedModules": true,
10+
"skipLibCheck": true,
11+
"noEmit": true,
12+
"strict": true,
13+
"types": ["vitest/globals", "node"],
14+
"customConditions": ["@triggerdotdev/source"]
15+
},
16+
"exclude": ["node_modules", "dist"]
17+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: { include: ["**/*.test.ts"], globals: true, isolate: true, testTimeout: 10_000 },
5+
});

pnpm-lock.yaml

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)