@facilis.dev/analyze 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/analyze.d.ts +17 -0
- package/dist/analyze.js +99 -0
- package/dist/ast/node.d.ts +29 -0
- package/dist/ast/node.js +85 -0
- package/dist/ast/walk.d.ts +8 -0
- package/dist/ast/walk.js +40 -0
- package/dist/diagnostics.d.ts +5 -0
- package/dist/diagnostics.js +12 -0
- package/dist/fs.d.ts +13 -0
- package/dist/fs.js +51 -0
- package/dist/graph-edges.d.ts +3 -0
- package/dist/graph-edges.js +148 -0
- package/dist/graph-ids.d.ts +6 -0
- package/dist/graph-ids.js +22 -0
- package/dist/graph.d.ts +15 -0
- package/dist/graph.js +162 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +17 -0
- package/dist/json.d.ts +8 -0
- package/dist/json.js +31 -0
- package/dist/module-graph.d.ts +33 -0
- package/dist/module-graph.js +86 -0
- package/dist/module-info.d.ts +27 -0
- package/dist/module-info.js +143 -0
- package/dist/node.d.ts +16 -0
- package/dist/node.js +41 -0
- package/dist/parser.d.ts +16 -0
- package/dist/parser.js +1 -0
- package/dist/passes/clients.d.ts +22 -0
- package/dist/passes/clients.js +167 -0
- package/dist/passes/config.d.ts +16 -0
- package/dist/passes/config.js +229 -0
- package/dist/passes/file-facts.d.ts +13 -0
- package/dist/passes/file-facts.js +130 -0
- package/dist/passes/i18n.d.ts +14 -0
- package/dist/passes/i18n.js +55 -0
- package/dist/passes/job-relations-diagnostics.d.ts +9 -0
- package/dist/passes/job-relations-diagnostics.js +38 -0
- package/dist/passes/job-relations.d.ts +18 -0
- package/dist/passes/job-relations.js +52 -0
- package/dist/passes/jobs.d.ts +20 -0
- package/dist/passes/jobs.js +133 -0
- package/dist/passes/jsx-text.d.ts +28 -0
- package/dist/passes/jsx-text.js +121 -0
- package/dist/passes/jsx.d.ts +24 -0
- package/dist/passes/jsx.js +135 -0
- package/dist/passes/path-expression.d.ts +26 -0
- package/dist/passes/path-expression.js +265 -0
- package/dist/passes/route-config.d.ts +24 -0
- package/dist/passes/route-config.js +152 -0
- package/dist/passes/route-description.d.ts +13 -0
- package/dist/passes/route-description.js +23 -0
- package/dist/passes/route-elevation.d.ts +18 -0
- package/dist/passes/route-elevation.js +136 -0
- package/dist/passes/route-handler.d.ts +27 -0
- package/dist/passes/route-handler.js +87 -0
- package/dist/passes/route-location.d.ts +16 -0
- package/dist/passes/route-location.js +58 -0
- package/dist/passes/route-relation-resolve.d.ts +51 -0
- package/dist/passes/route-relation-resolve.js +172 -0
- package/dist/passes/route-relations-diagnostics.d.ts +15 -0
- package/dist/passes/route-relations-diagnostics.js +79 -0
- package/dist/passes/route-relations-follow.d.ts +16 -0
- package/dist/passes/route-relations-follow.js +116 -0
- package/dist/passes/route-relations-usage.d.ts +50 -0
- package/dist/passes/route-relations-usage.js +44 -0
- package/dist/passes/route-relations-walk.d.ts +25 -0
- package/dist/passes/route-relations-walk.js +199 -0
- package/dist/passes/route-relations.d.ts +17 -0
- package/dist/passes/route-relations.js +43 -0
- package/dist/passes/route-table.d.ts +21 -0
- package/dist/passes/route-table.js +104 -0
- package/dist/passes/route-tables.d.ts +10 -0
- package/dist/passes/route-tables.js +42 -0
- package/dist/passes/routes.d.ts +18 -0
- package/dist/passes/routes.js +152 -0
- package/dist/passes/schema-column-types.d.ts +9 -0
- package/dist/passes/schema-column-types.js +55 -0
- package/dist/passes/schema-columns.d.ts +35 -0
- package/dist/passes/schema-columns.js +197 -0
- package/dist/passes/schema-dsl.d.ts +24 -0
- package/dist/passes/schema-dsl.js +117 -0
- package/dist/passes/schema-file-links-ddl.d.ts +18 -0
- package/dist/passes/schema-file-links-ddl.js +106 -0
- package/dist/passes/schema-file-links.d.ts +19 -0
- package/dist/passes/schema-file-links.js +144 -0
- package/dist/passes/schema-foreign-keys.d.ts +21 -0
- package/dist/passes/schema-foreign-keys.js +80 -0
- package/dist/passes/schema-policies.d.ts +15 -0
- package/dist/passes/schema-policies.js +201 -0
- package/dist/passes/schema-re-exports.d.ts +5 -0
- package/dist/passes/schema-re-exports.js +77 -0
- package/dist/passes/schema-relations.d.ts +8 -0
- package/dist/passes/schema-relations.js +182 -0
- package/dist/passes/schema-scope.d.ts +7 -0
- package/dist/passes/schema-scope.js +20 -0
- package/dist/passes/schema-tables.d.ts +13 -0
- package/dist/passes/schema-tables.js +149 -0
- package/dist/passes/schema.d.ts +9 -0
- package/dist/passes/schema.js +55 -0
- package/dist/passes/screen-components.d.ts +22 -0
- package/dist/passes/screen-components.js +143 -0
- package/dist/passes/screen-intrinsics.d.ts +18 -0
- package/dist/passes/screen-intrinsics.js +21 -0
- package/dist/passes/screen-preview.d.ts +29 -0
- package/dist/passes/screen-preview.js +174 -0
- package/dist/passes/screens.d.ts +12 -0
- package/dist/passes/screens.js +278 -0
- package/dist/passes/static-read.d.ts +47 -0
- package/dist/passes/static-read.js +151 -0
- package/dist/passes/views.d.ts +20 -0
- package/dist/passes/views.js +101 -0
- package/dist/passes/workflow-tables.d.ts +18 -0
- package/dist/passes/workflow-tables.js +71 -0
- package/dist/passes/workflows.d.ts +8 -0
- package/dist/passes/workflows.js +160 -0
- package/dist/path-resolver.d.ts +20 -0
- package/dist/path-resolver.js +134 -0
- package/dist/revision.d.ts +2 -0
- package/dist/revision.js +23 -0
- package/dist/source.d.ts +4 -0
- package/dist/source.js +13 -0
- package/dist/static-eval.d.ts +36 -0
- package/dist/static-eval.js +215 -0
- package/dist/types-graph.d.ts +144 -0
- package/dist/types-graph.js +4 -0
- package/dist/types-jobs.d.ts +28 -0
- package/dist/types-jobs.js +14 -0
- package/dist/types-relations.d.ts +49 -0
- package/dist/types-relations.js +11 -0
- package/dist/types-workflows.d.ts +36 -0
- package/dist/types-workflows.js +3 -0
- package/dist/types.d.ts +226 -0
- package/dist/types.js +1 -0
- package/package.json +49 -4
- package/src/analyze.ts +119 -0
- package/src/ast/node.ts +102 -0
- package/src/ast/walk.ts +37 -0
- package/src/diagnostics.ts +22 -0
- package/src/fs.ts +59 -0
- package/src/graph-edges.ts +167 -0
- package/src/graph-ids.ts +31 -0
- package/src/graph.ts +194 -0
- package/src/index.ts +26 -0
- package/src/json.ts +34 -0
- package/src/module-graph.ts +117 -0
- package/src/module-info.ts +162 -0
- package/src/node.ts +54 -0
- package/src/parser.ts +19 -0
- package/src/passes/clients.ts +233 -0
- package/src/passes/config.ts +278 -0
- package/src/passes/file-facts.ts +142 -0
- package/src/passes/i18n.ts +66 -0
- package/src/passes/job-relations-diagnostics.ts +60 -0
- package/src/passes/job-relations.ts +76 -0
- package/src/passes/jobs.ts +181 -0
- package/src/passes/jsx-text.ts +126 -0
- package/src/passes/jsx.ts +142 -0
- package/src/passes/path-expression.ts +297 -0
- package/src/passes/route-config.ts +188 -0
- package/src/passes/route-description.ts +33 -0
- package/src/passes/route-elevation.ts +161 -0
- package/src/passes/route-handler.ts +105 -0
- package/src/passes/route-location.ts +62 -0
- package/src/passes/route-relation-resolve.ts +228 -0
- package/src/passes/route-relations-diagnostics.ts +115 -0
- package/src/passes/route-relations-follow.ts +146 -0
- package/src/passes/route-relations-usage.ts +98 -0
- package/src/passes/route-relations-walk.ts +255 -0
- package/src/passes/route-relations.ts +62 -0
- package/src/passes/route-table.ts +123 -0
- package/src/passes/route-tables.ts +50 -0
- package/src/passes/routes.ts +188 -0
- package/src/passes/schema-column-types.ts +62 -0
- package/src/passes/schema-columns.ts +287 -0
- package/src/passes/schema-dsl.ts +136 -0
- package/src/passes/schema-file-links-ddl.ts +125 -0
- package/src/passes/schema-file-links.ts +213 -0
- package/src/passes/schema-foreign-keys.ts +121 -0
- package/src/passes/schema-policies.ts +270 -0
- package/src/passes/schema-re-exports.ts +95 -0
- package/src/passes/schema-relations.ts +239 -0
- package/src/passes/schema-scope.ts +29 -0
- package/src/passes/schema-tables.ts +216 -0
- package/src/passes/schema.ts +67 -0
- package/src/passes/screen-components.ts +162 -0
- package/src/passes/screen-intrinsics.ts +29 -0
- package/src/passes/screen-preview.ts +295 -0
- package/src/passes/screens.ts +291 -0
- package/src/passes/static-read.ts +196 -0
- package/src/passes/views.ts +125 -0
- package/src/passes/workflow-tables.ts +87 -0
- package/src/passes/workflows.ts +213 -0
- package/src/path-resolver.ts +146 -0
- package/src/revision.ts +25 -0
- package/src/source.ts +15 -0
- package/src/static-eval.ts +242 -0
- package/src/types-graph.ts +176 -0
- package/src/types-jobs.ts +40 -0
- package/src/types-relations.ts +64 -0
- package/src/types-workflows.ts +42 -0
- package/src/types.ts +299 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { identifierName, isAstNode, literalString, nodeArray, type AstNode } from "./ast/node.ts";
|
|
2
|
+
import type { SourceRef } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface ImportBinding {
|
|
5
|
+
readonly specifier: string;
|
|
6
|
+
readonly imported: string | null;
|
|
7
|
+
readonly local: string;
|
|
8
|
+
readonly kind: "named" | "default" | "namespace";
|
|
9
|
+
readonly source: SourceRef;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ExportBinding {
|
|
13
|
+
readonly exported: string;
|
|
14
|
+
readonly local: string | null;
|
|
15
|
+
readonly source: string | null;
|
|
16
|
+
readonly imported: string | null;
|
|
17
|
+
readonly kind: "named" | "default" | "reexport" | "all";
|
|
18
|
+
readonly ref: SourceRef;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ModuleInfo {
|
|
22
|
+
readonly imports: readonly ImportBinding[];
|
|
23
|
+
readonly exports: readonly ExportBinding[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function refOf(file: string, source: string, node: AstNode): SourceRef {
|
|
27
|
+
const offset = typeof node.start === "number" ? node.start : 0;
|
|
28
|
+
let line = 1;
|
|
29
|
+
for (let index = 0; index < offset && index < source.length; index += 1) {
|
|
30
|
+
if (source.charCodeAt(index) === 10) line += 1;
|
|
31
|
+
}
|
|
32
|
+
return { file, line };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function importBindings(file: string, source: string, program: AstNode): ImportBinding[] {
|
|
36
|
+
const out: ImportBinding[] = [];
|
|
37
|
+
for (const statement of nodeArray(program, "body")) {
|
|
38
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
39
|
+
const specifier = literalString(statement.source);
|
|
40
|
+
if (specifier === undefined) continue;
|
|
41
|
+
const ref = refOf(file, source, statement);
|
|
42
|
+
for (const item of nodeArray(statement, "specifiers")) {
|
|
43
|
+
if (item.type === "ImportDefaultSpecifier") {
|
|
44
|
+
const local = identifierName(item.local);
|
|
45
|
+
if (local !== undefined) {
|
|
46
|
+
out.push({ specifier, imported: "default", local, kind: "default", source: ref });
|
|
47
|
+
}
|
|
48
|
+
} else if (item.type === "ImportNamespaceSpecifier") {
|
|
49
|
+
const local = identifierName(item.local);
|
|
50
|
+
if (local !== undefined) {
|
|
51
|
+
out.push({ specifier, imported: null, local, kind: "namespace", source: ref });
|
|
52
|
+
}
|
|
53
|
+
} else if (item.type === "ImportSpecifier") {
|
|
54
|
+
const local = identifierName(item.local);
|
|
55
|
+
const imported = identifierName(item.imported) ?? literalString(item.imported);
|
|
56
|
+
if (local !== undefined && imported !== undefined) {
|
|
57
|
+
out.push({ specifier, imported, local, kind: "named", source: ref });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function declarationNames(declaration: AstNode): string[] {
|
|
66
|
+
if (declaration.type === "VariableDeclaration") {
|
|
67
|
+
return nodeArray(declaration, "declarations")
|
|
68
|
+
.map((item) => identifierName(item.id))
|
|
69
|
+
.filter((name): name is string => name !== undefined);
|
|
70
|
+
}
|
|
71
|
+
if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") {
|
|
72
|
+
const name = identifierName(declaration.id);
|
|
73
|
+
return name === undefined ? [] : [name];
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function exportBindings(file: string, source: string, program: AstNode): ExportBinding[] {
|
|
79
|
+
const out: ExportBinding[] = [];
|
|
80
|
+
for (const statement of nodeArray(program, "body")) {
|
|
81
|
+
const ref = refOf(file, source, statement);
|
|
82
|
+
if (statement.type === "ExportDefaultDeclaration") {
|
|
83
|
+
const declaration = isAstNode(statement.declaration) ? statement.declaration : undefined;
|
|
84
|
+
const local = declaration === undefined ? null : (identifierName(declaration) ?? null);
|
|
85
|
+
out.push({ exported: "default", local, source: null, imported: null, kind: "default", ref });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (statement.type === "ExportAllDeclaration") {
|
|
89
|
+
const specifier = literalString(statement.source);
|
|
90
|
+
if (specifier !== undefined) {
|
|
91
|
+
out.push({
|
|
92
|
+
exported: "*",
|
|
93
|
+
local: null,
|
|
94
|
+
source: specifier,
|
|
95
|
+
imported: null,
|
|
96
|
+
kind: "all",
|
|
97
|
+
ref,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (statement.type !== "ExportNamedDeclaration") continue;
|
|
103
|
+
const declaration = isAstNode(statement.declaration) ? statement.declaration : undefined;
|
|
104
|
+
if (declaration !== undefined) {
|
|
105
|
+
for (const name of declarationNames(declaration)) {
|
|
106
|
+
out.push({ exported: name, local: name, source: null, imported: null, kind: "named", ref });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const from = literalString(statement.source);
|
|
110
|
+
for (const item of nodeArray(statement, "specifiers")) {
|
|
111
|
+
if (item.type !== "ExportSpecifier") continue;
|
|
112
|
+
const exported = identifierName(item.exported) ?? literalString(item.exported);
|
|
113
|
+
const local = identifierName(item.local) ?? literalString(item.local);
|
|
114
|
+
if (exported === undefined || local === undefined) continue;
|
|
115
|
+
out.push({
|
|
116
|
+
exported,
|
|
117
|
+
local: from === undefined ? local : null,
|
|
118
|
+
source: from ?? null,
|
|
119
|
+
imported: from === undefined ? null : local,
|
|
120
|
+
kind: from === undefined ? "named" : "reexport",
|
|
121
|
+
ref,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function parseModuleInfo(file: string, source: string, program: AstNode): ModuleInfo {
|
|
129
|
+
return {
|
|
130
|
+
imports: importBindings(file, source, program),
|
|
131
|
+
exports: exportBindings(file, source, program),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The declaration of "export default ...", unwrapped from the export statement. */
|
|
136
|
+
export function defaultExportDeclaration(program: AstNode): AstNode | undefined {
|
|
137
|
+
for (const statement of nodeArray(program, "body")) {
|
|
138
|
+
if (statement.type !== "ExportDefaultDeclaration") continue;
|
|
139
|
+
return isAstNode(statement.declaration) ? statement.declaration : undefined;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The initializer of a top-level const/let/var binding, when declared exactly once. */
|
|
145
|
+
export function localInitializer(program: AstNode, name: string): AstNode | undefined {
|
|
146
|
+
let found: AstNode | undefined;
|
|
147
|
+
for (const statement of nodeArray(program, "body")) {
|
|
148
|
+
const declaration =
|
|
149
|
+
statement.type === "VariableDeclaration"
|
|
150
|
+
? statement
|
|
151
|
+
: statement.type === "ExportNamedDeclaration" && isAstNode(statement.declaration)
|
|
152
|
+
? statement.declaration
|
|
153
|
+
: undefined;
|
|
154
|
+
if (declaration === undefined || declaration.type !== "VariableDeclaration") continue;
|
|
155
|
+
for (const item of nodeArray(declaration, "declarations")) {
|
|
156
|
+
if (identifierName(item.id) !== name) continue;
|
|
157
|
+
if (found !== undefined) return undefined;
|
|
158
|
+
found = isAstNode(item.init) ? item.init : undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return found;
|
|
162
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { parseSync } from "oxc-parser";
|
|
2
|
+
|
|
3
|
+
import { analyzeProject, type AnalyzeInput } from "./analyze.ts";
|
|
4
|
+
import { asNode } from "./ast/node.ts";
|
|
5
|
+
import type { PathAlias } from "./path-resolver.ts";
|
|
6
|
+
import { loadProjectFiles, type LoadProjectOptions } from "./fs.ts";
|
|
7
|
+
import type { ParseError, Parser } from "./parser.ts";
|
|
8
|
+
import { lineOf } from "./source.ts";
|
|
9
|
+
import type { Analysis } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
/** Node/CI parser adapter. The browser uses a wasm adapter over the same contract. */
|
|
12
|
+
export function createOxcParser(): Parser {
|
|
13
|
+
return {
|
|
14
|
+
parse(filename: string, source: string) {
|
|
15
|
+
const result = parseSync(filename, source, {
|
|
16
|
+
astType: "ts",
|
|
17
|
+
sourceType: "module",
|
|
18
|
+
preserveParens: true,
|
|
19
|
+
});
|
|
20
|
+
const program = asNode(result.program as unknown);
|
|
21
|
+
if (program === undefined) throw new Error("oxc-parser returned no program");
|
|
22
|
+
const errors: ParseError[] = result.errors.map((error) => {
|
|
23
|
+
const label = error.labels[0];
|
|
24
|
+
const start = label === undefined ? null : label.start;
|
|
25
|
+
return {
|
|
26
|
+
message: error.message,
|
|
27
|
+
start,
|
|
28
|
+
end: label === undefined ? null : label.end,
|
|
29
|
+
source: { file: filename, line: start === null ? 1 : lineOf(source, start) },
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
return { program, errors };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AnalyzeDirectoryOptions {
|
|
38
|
+
readonly aliases?: readonly PathAlias[];
|
|
39
|
+
readonly transport?: AnalyzeInput["transport"];
|
|
40
|
+
readonly load?: LoadProjectOptions;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { loadProjectFiles } from "./fs.ts";
|
|
44
|
+
export type { LoadProjectOptions } from "./fs.ts";
|
|
45
|
+
|
|
46
|
+
/** Convenience for Node/CI: load a directory and analyze it. The browser passes its own map. */
|
|
47
|
+
export function analyzeDirectory(root: string, options: AnalyzeDirectoryOptions = {}): Analysis {
|
|
48
|
+
return analyzeProject({
|
|
49
|
+
files: loadProjectFiles(root, options.load),
|
|
50
|
+
parser: createOxcParser(),
|
|
51
|
+
...(options.aliases === undefined ? {} : { aliases: options.aliases }),
|
|
52
|
+
...(options.transport === undefined ? {} : { transport: options.transport }),
|
|
53
|
+
});
|
|
54
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AstNode } from "./ast/node.ts";
|
|
2
|
+
import type { SourceRef } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface ParseError {
|
|
5
|
+
readonly message: string;
|
|
6
|
+
readonly start: number | null;
|
|
7
|
+
readonly end: number | null;
|
|
8
|
+
readonly source: SourceRef;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ParsedModule {
|
|
12
|
+
readonly program: AstNode;
|
|
13
|
+
readonly errors: readonly ParseError[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Injected parser contract: the analyzer never imports a parser package itself. */
|
|
17
|
+
export interface Parser {
|
|
18
|
+
parse(filename: string, source: string): ParsedModule;
|
|
19
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client consumers -> PageFacts and page→route edges (§3.4 of the AST DSL contract).
|
|
3
|
+
*
|
|
4
|
+
* Exactly one transport is sanctioned (`api()` from `src/lib/api.ts` by default): the
|
|
5
|
+
* transport export is resolved through imports (aliases and namespace imports included) and
|
|
6
|
+
* every other network call in `src/**` is a `client-transport` error. The transport module
|
|
7
|
+
* itself is the only place allowed to call `fetch`, so it is not scanned as a consumer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
callArguments,
|
|
12
|
+
identifierName,
|
|
13
|
+
memberName,
|
|
14
|
+
unwrapExpression,
|
|
15
|
+
type AstNode,
|
|
16
|
+
} from "../ast/node.ts";
|
|
17
|
+
import { walk } from "../ast/walk.ts";
|
|
18
|
+
import { error, warning } from "../diagnostics.ts";
|
|
19
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
20
|
+
import type { StaticContext } from "../static-eval.ts";
|
|
21
|
+
import type {
|
|
22
|
+
Diagnostic,
|
|
23
|
+
HttpMethod,
|
|
24
|
+
PageFact,
|
|
25
|
+
PageKind,
|
|
26
|
+
RouteCallFact,
|
|
27
|
+
RouteFact,
|
|
28
|
+
SourceRef,
|
|
29
|
+
UnknownFact,
|
|
30
|
+
} from "../types.ts";
|
|
31
|
+
import { extractPathExpression, matchRouteIds } from "./path-expression.ts";
|
|
32
|
+
import {
|
|
33
|
+
projectPath,
|
|
34
|
+
readStaticObject,
|
|
35
|
+
relativePath,
|
|
36
|
+
refOf,
|
|
37
|
+
sortDiagnostics,
|
|
38
|
+
} from "./static-read.ts";
|
|
39
|
+
|
|
40
|
+
const SRC_DIR = "src/";
|
|
41
|
+
const HTTP_METHODS = new Set([
|
|
42
|
+
"GET",
|
|
43
|
+
"POST",
|
|
44
|
+
"PUT",
|
|
45
|
+
"PATCH",
|
|
46
|
+
"DELETE",
|
|
47
|
+
"HEAD",
|
|
48
|
+
"OPTIONS",
|
|
49
|
+
"TRACE",
|
|
50
|
+
"CONNECT",
|
|
51
|
+
]);
|
|
52
|
+
const NETWORK_CALLEES = new Set(["fetch", "axios", "ky", "got"]);
|
|
53
|
+
|
|
54
|
+
export interface TransportSpec {
|
|
55
|
+
/** Project-relative module, e.g. `src/lib/api.ts`. */
|
|
56
|
+
readonly module: string;
|
|
57
|
+
/** Exported function that performs API calls, e.g. `api`. */
|
|
58
|
+
readonly exportName: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ClientResult {
|
|
62
|
+
readonly pages: readonly PageFact[];
|
|
63
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface TransportBindings {
|
|
67
|
+
readonly names: ReadonlySet<string>;
|
|
68
|
+
readonly namespaces: ReadonlySet<string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function transportBindings(
|
|
72
|
+
graph: ModuleGraph,
|
|
73
|
+
file: ProjectFile,
|
|
74
|
+
transportPath: string,
|
|
75
|
+
exportName: string,
|
|
76
|
+
): TransportBindings {
|
|
77
|
+
const names = new Set<string>();
|
|
78
|
+
const namespaces = new Set<string>();
|
|
79
|
+
for (const imported of file.module.imports) {
|
|
80
|
+
if (graph.resolve(file.path, imported.specifier) !== transportPath) continue;
|
|
81
|
+
if (imported.kind === "namespace") namespaces.add(imported.local);
|
|
82
|
+
else if ((imported.imported ?? "default") === exportName) names.add(imported.local);
|
|
83
|
+
}
|
|
84
|
+
return { names, namespaces };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isTransportCall(call: AstNode, bindings: TransportBindings, exportName: string): boolean {
|
|
88
|
+
const callee = unwrapExpression(call.callee);
|
|
89
|
+
const direct = identifierName(callee);
|
|
90
|
+
if (direct !== undefined) return bindings.names.has(direct);
|
|
91
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
92
|
+
const objectName = identifierName(unwrapExpression(callee.object));
|
|
93
|
+
return (
|
|
94
|
+
objectName !== undefined &&
|
|
95
|
+
bindings.namespaces.has(objectName) &&
|
|
96
|
+
memberName(callee) === exportName
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Name of a network call that is not the transport, or null when the node is not one. */
|
|
101
|
+
function networkCallName(node: AstNode): string | null {
|
|
102
|
+
if (node.type === "NewExpression") {
|
|
103
|
+
return identifierName(unwrapExpression(node.callee)) === "XMLHttpRequest"
|
|
104
|
+
? "XMLHttpRequest"
|
|
105
|
+
: null;
|
|
106
|
+
}
|
|
107
|
+
if (node.type !== "CallExpression") return null;
|
|
108
|
+
const callee = unwrapExpression(node.callee);
|
|
109
|
+
const direct = identifierName(callee);
|
|
110
|
+
if (direct !== undefined && NETWORK_CALLEES.has(direct)) return direct;
|
|
111
|
+
if (callee?.type !== "MemberExpression") return null;
|
|
112
|
+
const objectName = identifierName(unwrapExpression(callee.object));
|
|
113
|
+
return objectName !== undefined && NETWORK_CALLEES.has(objectName) ? objectName : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface MethodResult {
|
|
117
|
+
readonly method: HttpMethod;
|
|
118
|
+
readonly unknown: UnknownFact | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The request method comes from the second argument's static `method`, else GET. */
|
|
122
|
+
function requestMethod(
|
|
123
|
+
init: AstNode | undefined,
|
|
124
|
+
context: StaticContext,
|
|
125
|
+
callRef: SourceRef,
|
|
126
|
+
): MethodResult {
|
|
127
|
+
if (init === undefined) return { method: "GET", unknown: null };
|
|
128
|
+
const object = readStaticObject(init, context, { code: "page-path-static", report: false });
|
|
129
|
+
const field = object.fields.get("method");
|
|
130
|
+
if (field !== undefined && field.ok && typeof field.value === "string") {
|
|
131
|
+
const method = field.value.toUpperCase();
|
|
132
|
+
if (HTTP_METHODS.has(method)) return { method: method as HttpMethod, unknown: null };
|
|
133
|
+
}
|
|
134
|
+
// An unreadable options object hides the method; defaulting silently could produce a wrong
|
|
135
|
+
// page→route edge, so the assumption is recorded as unknown.
|
|
136
|
+
const unknown: UnknownFact | null =
|
|
137
|
+
field === undefined && object.unknown.length === 0
|
|
138
|
+
? null
|
|
139
|
+
: {
|
|
140
|
+
code: "page-path-static",
|
|
141
|
+
reason: "the request method cannot be read statically; assuming GET",
|
|
142
|
+
source: callRef,
|
|
143
|
+
};
|
|
144
|
+
return { method: "GET", unknown };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pageKind(relative: string): PageKind {
|
|
148
|
+
if (relative.startsWith("src/pages/")) return "page";
|
|
149
|
+
if (relative.startsWith("src/components/")) return "component";
|
|
150
|
+
if (relative.startsWith("src/lib/")) return "lib";
|
|
151
|
+
return "other";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function scanPage(
|
|
155
|
+
graph: ModuleGraph,
|
|
156
|
+
file: ProjectFile,
|
|
157
|
+
routes: readonly RouteFact[],
|
|
158
|
+
transportPath: string,
|
|
159
|
+
exportName: string,
|
|
160
|
+
): { page: PageFact; diagnostics: readonly Diagnostic[] } {
|
|
161
|
+
const context: StaticContext = { graph, file: file.path };
|
|
162
|
+
const bindings = transportBindings(graph, file, transportPath, exportName);
|
|
163
|
+
const calls: RouteCallFact[] = [];
|
|
164
|
+
const diagnostics: Diagnostic[] = [];
|
|
165
|
+
|
|
166
|
+
walk(file.program, (node) => {
|
|
167
|
+
if (node.type !== "CallExpression" && node.type !== "NewExpression") return;
|
|
168
|
+
const transport = node.type === "CallExpression" && isTransportCall(node, bindings, exportName);
|
|
169
|
+
const network = transport ? null : networkCallName(node);
|
|
170
|
+
if (!transport && network === null) return;
|
|
171
|
+
|
|
172
|
+
const callRef = refOf(context, node);
|
|
173
|
+
const args = node.type === "CallExpression" ? callArguments(node) : [];
|
|
174
|
+
const extracted = extractPathExpression(args[0], context, node, "page-path-static");
|
|
175
|
+
const method = requestMethod(args[1], context, callRef);
|
|
176
|
+
const unknown: UnknownFact[] = [...extracted.unknown];
|
|
177
|
+
if (method.unknown !== null) unknown.push(method.unknown);
|
|
178
|
+
|
|
179
|
+
if (transport && extracted.path === null && extracted.staticPrefix === null) {
|
|
180
|
+
diagnostics.push(
|
|
181
|
+
warning("page-path-static", "the API path cannot be resolved statically", callRef),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (!transport && network !== null) {
|
|
185
|
+
const reason = network + " outside the sanctioned transport module is not allowed";
|
|
186
|
+
unknown.push({ code: "client-transport", reason, source: callRef });
|
|
187
|
+
diagnostics.push(error("client-transport", reason, callRef));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
calls.push({
|
|
191
|
+
expression: extracted.expression,
|
|
192
|
+
kind: extracted.kind,
|
|
193
|
+
staticPrefix: extracted.staticPrefix,
|
|
194
|
+
matchedRouteIds: matchRouteIds(routes, extracted.path, extracted.staticPrefix, method.method),
|
|
195
|
+
source: callRef,
|
|
196
|
+
unknown,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const page: PageFact = {
|
|
201
|
+
path: file.path,
|
|
202
|
+
kind: pageKind(relativePath(graph.root, file.path)),
|
|
203
|
+
calls,
|
|
204
|
+
source: { file: file.path, line: 1 },
|
|
205
|
+
unknown: [],
|
|
206
|
+
};
|
|
207
|
+
return { page, diagnostics };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Map client files to the routes they call. */
|
|
211
|
+
export function buildPageFacts(
|
|
212
|
+
graph: ModuleGraph,
|
|
213
|
+
routes: readonly RouteFact[],
|
|
214
|
+
transport: TransportSpec,
|
|
215
|
+
): ClientResult {
|
|
216
|
+
const transportPath = projectPath(graph.root, transport.module);
|
|
217
|
+
const files = [...graph.files.values()]
|
|
218
|
+
.filter((file) => {
|
|
219
|
+
const relative = relativePath(graph.root, file.path);
|
|
220
|
+
if (!relative.startsWith(SRC_DIR)) return false;
|
|
221
|
+
if (relative.includes(".test.")) return false;
|
|
222
|
+
return file.path !== transportPath;
|
|
223
|
+
})
|
|
224
|
+
.toSorted((left, right) => left.path.localeCompare(right.path));
|
|
225
|
+
const pages: PageFact[] = [];
|
|
226
|
+
const diagnostics: Diagnostic[] = [];
|
|
227
|
+
for (const file of files) {
|
|
228
|
+
const scanned = scanPage(graph, file, routes, transportPath, transport.exportName);
|
|
229
|
+
pages.push(scanned.page);
|
|
230
|
+
diagnostics.push(...scanned.diagnostics);
|
|
231
|
+
}
|
|
232
|
+
return { pages, diagnostics: sortDiagnostics(diagnostics) };
|
|
233
|
+
}
|