@facilis.dev/analyze 0.0.0-stage → 0.1.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/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 +158 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +15 -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.d.ts +22 -0
- package/dist/passes/jsx.js +89 -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-preview.d.ts +29 -0
- package/dist/passes/screen-preview.js +159 -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 +130 -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 +190 -0
- package/src/index.ts +24 -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.ts +92 -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-preview.ts +268 -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 +161 -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,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route-relations walker (D-157): starts at the route handler, tracks db clients and
|
|
3
|
+
* relation bindings through local declarations, records `from/join/insert/update/delete`,
|
|
4
|
+
* `db.query.<relation>` and `db.execute`, and hands the interprocedural cases to
|
|
5
|
+
* route-relations-follow.ts. Everything outside the static subset is explicit (opaque or
|
|
6
|
+
* unresolved), never guessed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
asNode,
|
|
11
|
+
callArguments,
|
|
12
|
+
identifierName,
|
|
13
|
+
memberName,
|
|
14
|
+
nodeArray,
|
|
15
|
+
objectEntries,
|
|
16
|
+
propertyName,
|
|
17
|
+
propertyValue,
|
|
18
|
+
unwrapExpression,
|
|
19
|
+
type AstNode,
|
|
20
|
+
} from "../ast/node.ts";
|
|
21
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
22
|
+
import { sourceRef } from "../source.ts";
|
|
23
|
+
import type { RouteRelationsFact } from "../types-relations.ts";
|
|
24
|
+
import type { ElevationKind, SourceRef } from "../types.ts";
|
|
25
|
+
import { followFunction, handleElevation } from "./route-relations-follow.ts";
|
|
26
|
+
import {
|
|
27
|
+
createUsage,
|
|
28
|
+
freezeUsage,
|
|
29
|
+
addUnresolved,
|
|
30
|
+
addUse,
|
|
31
|
+
markOpaque,
|
|
32
|
+
type MutableUsage,
|
|
33
|
+
type RelationScope,
|
|
34
|
+
} from "./route-relations-usage.ts";
|
|
35
|
+
import {
|
|
36
|
+
carriesClient,
|
|
37
|
+
chainClient,
|
|
38
|
+
clientOf,
|
|
39
|
+
resolveLocalFunction,
|
|
40
|
+
ROUTE_CLIENT,
|
|
41
|
+
type DbClient,
|
|
42
|
+
type RelationIndex,
|
|
43
|
+
} from "./route-relation-resolve.ts";
|
|
44
|
+
|
|
45
|
+
const MAX_DEPTH = 6;
|
|
46
|
+
const RELATION_METHODS = new Set(["from", "innerJoin", "leftJoin", "rightJoin", "fullJoin"]);
|
|
47
|
+
const WRITE_METHODS = new Set(["insert", "update", "delete"]);
|
|
48
|
+
const SKIPPED_KEYS = new Set(["type", "start", "end", "range"]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How a scanned function receives its db client. Route handlers are `(event, ctx)` and may
|
|
52
|
+
* elevate; a job's `run` is `(ctx)` and already runs system-scoped, so it has no elevations.
|
|
53
|
+
*/
|
|
54
|
+
export interface UsageSeed {
|
|
55
|
+
/** Index of the parameter that carries the db context (route: 1, job run: 0). */
|
|
56
|
+
readonly contextParam: number;
|
|
57
|
+
/** Route handlers may call ctx.sudo/ctx.system; jobs may not (D-157). */
|
|
58
|
+
readonly elevations: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ROUTE_SEED: UsageSeed = { contextParam: 1, elevations: true };
|
|
62
|
+
|
|
63
|
+
function elevationKind(name: string | undefined): ElevationKind | null {
|
|
64
|
+
return name === "sudo" || name === "system" ? name : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function refOf(scope: RelationScope, node: AstNode): SourceRef {
|
|
68
|
+
return sourceRef(
|
|
69
|
+
scope.file.path,
|
|
70
|
+
scope.file.source,
|
|
71
|
+
typeof node.start === "number" ? node.start : 0,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A callee that is `ctx.sudo`/`ctx.system` or a destructured/forwarded elevation handler. */
|
|
76
|
+
function calleeElevationKind(
|
|
77
|
+
callee: AstNode | undefined,
|
|
78
|
+
scope: RelationScope,
|
|
79
|
+
): ElevationKind | null {
|
|
80
|
+
if (!scope.elevations) return null;
|
|
81
|
+
const expression = unwrapExpression(callee);
|
|
82
|
+
if (expression === undefined) return null;
|
|
83
|
+
if (expression.type === "Identifier") {
|
|
84
|
+
return scope.elevationFns.get(identifierName(expression) ?? "") ?? null;
|
|
85
|
+
}
|
|
86
|
+
if (expression.type !== "MemberExpression") return null;
|
|
87
|
+
const kind = elevationKind(memberName(expression));
|
|
88
|
+
if (kind === null) return null;
|
|
89
|
+
const object = unwrapExpression(expression.object);
|
|
90
|
+
return object?.type === "Identifier" && identifierName(object) === scope.contextName
|
|
91
|
+
? kind
|
|
92
|
+
: null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `db.query.<relation>`: a static property name the runtime guard resolves to a relation. */
|
|
96
|
+
function trackQueryMember(node: AstNode, scope: RelationScope): void {
|
|
97
|
+
const property = memberName(node);
|
|
98
|
+
const object = unwrapExpression(node.object);
|
|
99
|
+
if (property === undefined || object?.type !== "MemberExpression") return;
|
|
100
|
+
if (memberName(object) !== "query") return;
|
|
101
|
+
if (clientOf(object.object, scope.env, scope.contextName) === null) return;
|
|
102
|
+
if (scope.index.names.has(property)) addUse(scope, property, refOf(scope, node));
|
|
103
|
+
else addUnresolved(scope, refOf(scope, node));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `const scoped = ctx.db` / `const rows = todos`: aliases the walk keeps resolving. */
|
|
107
|
+
function trackDeclaration(node: AstNode, scope: RelationScope): void {
|
|
108
|
+
if (node.type !== "VariableDeclarator") return;
|
|
109
|
+
const name = identifierName(node.id);
|
|
110
|
+
if (name === undefined) return;
|
|
111
|
+
const client = clientOf(node.init, scope.env, scope.contextName);
|
|
112
|
+
if (client !== null) {
|
|
113
|
+
scope.env.set(name, client);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const resolution = scope.index.resolve(scope.graph, scope.file, asNode(node.init), scope.aliases);
|
|
117
|
+
if (resolution.kind === "resolved") scope.aliases.set(name, resolution.name);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function recordRelation(scope: RelationScope, node: AstNode | undefined): void {
|
|
121
|
+
if (node === undefined) return;
|
|
122
|
+
const resolution = scope.index.resolve(scope.graph, scope.file, node, scope.aliases);
|
|
123
|
+
if (resolution.kind === "resolved") addUse(scope, resolution.name, refOf(scope, node));
|
|
124
|
+
else if (resolution.kind === "unresolved") addUnresolved(scope, refOf(scope, node));
|
|
125
|
+
else markOpaque(scope);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleCall(node: AstNode, scope: RelationScope): boolean {
|
|
129
|
+
const callee = unwrapExpression(node.callee);
|
|
130
|
+
const kind = calleeElevationKind(callee, scope);
|
|
131
|
+
if (kind !== null) return handleElevation(visit, node, scope, kind);
|
|
132
|
+
if (callee?.type === "MemberExpression") {
|
|
133
|
+
const name = memberName(callee);
|
|
134
|
+
const object = unwrapExpression(callee.object);
|
|
135
|
+
if (name === "execute" && chainClient(object, scope.env, scope.contextName) !== null) {
|
|
136
|
+
scope.usage.rawSql.push(refOf(scope, node));
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
name !== undefined &&
|
|
141
|
+
RELATION_METHODS.has(name) &&
|
|
142
|
+
chainClient(object, scope.env, scope.contextName) !== null
|
|
143
|
+
) {
|
|
144
|
+
recordRelation(scope, callArguments(node)[0]);
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
name !== undefined &&
|
|
149
|
+
WRITE_METHODS.has(name) &&
|
|
150
|
+
clientOf(object, scope.env, scope.contextName) !== null
|
|
151
|
+
) {
|
|
152
|
+
recordRelation(scope, callArguments(node)[0]);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const local = resolveLocalFunction(scope.graph, scope.file, callee);
|
|
157
|
+
if (local !== null) {
|
|
158
|
+
followFunction(visit, node, scope, local);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
// A project-external callee that receives the client makes the usage opaque: the runtime
|
|
162
|
+
// guard is the authority, so the pass must not raise a false undeclared/unused error.
|
|
163
|
+
if (
|
|
164
|
+
callArguments(node).some((argument) => carriesClient(argument, scope.env, scope.contextName))
|
|
165
|
+
) {
|
|
166
|
+
markOpaque(scope);
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function visit(node: AstNode, scope: RelationScope): void {
|
|
172
|
+
if (scope.depth > MAX_DEPTH) return;
|
|
173
|
+
trackDeclaration(node, scope);
|
|
174
|
+
if (node.type === "MemberExpression") trackQueryMember(node, scope);
|
|
175
|
+
if (node.type === "CallExpression" && handleCall(node, scope)) return;
|
|
176
|
+
for (const key of Object.keys(node)) {
|
|
177
|
+
if (SKIPPED_KEYS.has(key)) continue;
|
|
178
|
+
const value = node[key];
|
|
179
|
+
if (Array.isArray(value)) {
|
|
180
|
+
for (const child of value)
|
|
181
|
+
if (asNode(child) !== undefined) visit(asNode(child) as AstNode, scope);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const child = asNode(value);
|
|
185
|
+
if (child !== undefined) visit(child, scope);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function functionScope(
|
|
190
|
+
graph: ModuleGraph,
|
|
191
|
+
file: ProjectFile,
|
|
192
|
+
fn: AstNode,
|
|
193
|
+
index: RelationIndex,
|
|
194
|
+
usage: MutableUsage,
|
|
195
|
+
seed: UsageSeed,
|
|
196
|
+
): RelationScope {
|
|
197
|
+
const env = new Map<string, DbClient>();
|
|
198
|
+
const elevationFns = new Map<string, ElevationKind>();
|
|
199
|
+
let contextName: string | null = null;
|
|
200
|
+
const context = unwrapExpression(nodeArray(fn, "params")[seed.contextParam]);
|
|
201
|
+
if (context?.type === "Identifier") contextName = identifierName(context) ?? null;
|
|
202
|
+
else if (context?.type === "ObjectPattern") {
|
|
203
|
+
for (const entry of objectEntries(context)) {
|
|
204
|
+
if (entry.type !== "Property") continue;
|
|
205
|
+
const name = propertyName(entry);
|
|
206
|
+
let value = propertyValue(entry);
|
|
207
|
+
if (value?.type === "AssignmentPattern") value = unwrapExpression(value.left);
|
|
208
|
+
const local = identifierName(unwrapExpression(value));
|
|
209
|
+
if (local === undefined) continue;
|
|
210
|
+
if (name === "db") env.set(local, ROUTE_CLIENT);
|
|
211
|
+
else if (seed.elevations) {
|
|
212
|
+
const kind = elevationKind(name);
|
|
213
|
+
if (kind !== null) elevationFns.set(local, kind);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
graph,
|
|
219
|
+
index,
|
|
220
|
+
file,
|
|
221
|
+
env,
|
|
222
|
+
aliases: new Map(),
|
|
223
|
+
elevationFns,
|
|
224
|
+
contextName,
|
|
225
|
+
elevations: seed.elevations,
|
|
226
|
+
elevation: null,
|
|
227
|
+
depth: 0,
|
|
228
|
+
stack: new Set(),
|
|
229
|
+
usage,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Scan one route handler or job run function into its relation-usage fact. */
|
|
234
|
+
export function scanFunctionUsage(
|
|
235
|
+
graph: ModuleGraph,
|
|
236
|
+
file: ProjectFile,
|
|
237
|
+
fn: AstNode,
|
|
238
|
+
index: RelationIndex,
|
|
239
|
+
seed: UsageSeed,
|
|
240
|
+
): RouteRelationsFact {
|
|
241
|
+
const body = asNode(fn.body);
|
|
242
|
+
const usage = createUsage();
|
|
243
|
+
if (body !== undefined) visit(body, functionScope(graph, file, fn, index, usage, seed));
|
|
244
|
+
return freezeUsage(usage);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Scan one valid route handler into its relation-usage fact. */
|
|
248
|
+
export function scanHandlerUsage(
|
|
249
|
+
graph: ModuleGraph,
|
|
250
|
+
file: ProjectFile,
|
|
251
|
+
handler: AstNode,
|
|
252
|
+
index: RelationIndex,
|
|
253
|
+
): RouteRelationsFact {
|
|
254
|
+
return scanFunctionUsage(graph, file, handler, index, ROUTE_SEED);
|
|
255
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route handler -> relation usage (D-157), the pass that makes `dbTables` an applied
|
|
3
|
+
* declaration: what a route reaches through `ctx.db` and the audited `ctx.sudo`/`ctx.system`
|
|
4
|
+
* callback clients is compared with what it declares, tables and views alike.
|
|
5
|
+
*
|
|
6
|
+
* The walk lives in route-relations-walk.ts / route-relations-follow.ts and the diagnostics in
|
|
7
|
+
* route-relations-diagnostics.ts (300-line split). A route with an invalid wrapper keeps an
|
|
8
|
+
* explicitly empty usage: the wrapper error is the finding, not a guessed relation set.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { callArguments, unwrapExpression } from "../ast/node.ts";
|
|
12
|
+
import type { ModuleGraph } from "../module-graph.ts";
|
|
13
|
+
import type { RouteFact, TableFact, ViewFact, Diagnostic } from "../types.ts";
|
|
14
|
+
import { EMPTY_ROUTE_RELATIONS, type RouteRelationsFact } from "../types-relations.ts";
|
|
15
|
+
import { createRelationIndex } from "./route-relation-resolve.ts";
|
|
16
|
+
import { routeRelationDiagnostics } from "./route-relations-diagnostics.ts";
|
|
17
|
+
import { scanHandlerUsage } from "./route-relations-walk.ts";
|
|
18
|
+
import { resolveWrapperCall } from "./routes.ts";
|
|
19
|
+
|
|
20
|
+
export interface RouteRelationsScan {
|
|
21
|
+
readonly routes: readonly RouteFact[];
|
|
22
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function handlerOf(
|
|
26
|
+
call: ReturnType<typeof resolveWrapperCall>,
|
|
27
|
+
): ReturnType<typeof unwrapExpression> {
|
|
28
|
+
if (call === null) return undefined;
|
|
29
|
+
return unwrapExpression(callArguments(call)[1]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Attach relation-usage facts to every scannable route and derive the D-157 diagnostics. */
|
|
33
|
+
export function buildRouteRelationFacts(
|
|
34
|
+
graph: ModuleGraph,
|
|
35
|
+
routes: readonly RouteFact[],
|
|
36
|
+
tables: readonly TableFact[],
|
|
37
|
+
views: readonly ViewFact[],
|
|
38
|
+
): RouteRelationsScan {
|
|
39
|
+
const index = createRelationIndex(tables, views);
|
|
40
|
+
const enriched: RouteFact[] = [];
|
|
41
|
+
const diagnostics: Diagnostic[] = [];
|
|
42
|
+
for (const route of routes) {
|
|
43
|
+
const file = graph.file(route.sourceFile);
|
|
44
|
+
const handler =
|
|
45
|
+
file === undefined || route.wrapper !== "valid"
|
|
46
|
+
? undefined
|
|
47
|
+
: handlerOf(resolveWrapperCall(file));
|
|
48
|
+
if (
|
|
49
|
+
file === undefined ||
|
|
50
|
+
handler === undefined ||
|
|
51
|
+
(handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression")
|
|
52
|
+
) {
|
|
53
|
+
const relations: RouteRelationsFact = EMPTY_ROUTE_RELATIONS;
|
|
54
|
+
enriched.push({ ...route, relations });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const relations = scanHandlerUsage(graph, file, handler, index);
|
|
58
|
+
enriched.push({ ...route, relations });
|
|
59
|
+
diagnostics.push(...routeRelationDiagnostics(route, relations));
|
|
60
|
+
}
|
|
61
|
+
return { routes: enriched, diagnostics };
|
|
62
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The explicit react-router route table (`src/app.tsx`): URL paths and their element trees.
|
|
3
|
+
*
|
|
4
|
+
* The generated frontend contract keeps routing in one file with no file-based magic
|
|
5
|
+
* (docs/FRONTEND.md §2), so the table can be read statically. Only <Route>/<Routes> imported
|
|
6
|
+
* from react-router-dom count; a local component with the same name is not routing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { asNode, type AstNode } from "../ast/node.ts";
|
|
10
|
+
import { walk } from "../ast/walk.ts";
|
|
11
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
12
|
+
import { sourceRef } from "../source.ts";
|
|
13
|
+
import { evaluateStatic } from "../static-eval.ts";
|
|
14
|
+
import type { SourceRef } from "../types.ts";
|
|
15
|
+
import { elementName, jsxAttribute, jsxAttributeValue, jsxChildren } from "./jsx.ts";
|
|
16
|
+
|
|
17
|
+
export interface RouteEntry {
|
|
18
|
+
/** Normalized URL path; "*" for a catch-all route. */
|
|
19
|
+
readonly path: string;
|
|
20
|
+
/** The route's element JSX, when it is a JSX element rather than a component reference. */
|
|
21
|
+
readonly element: AstNode | null;
|
|
22
|
+
/** Index of the parent route entry (pathless layout routes are entries too). */
|
|
23
|
+
readonly parentIndex: number | null;
|
|
24
|
+
readonly source: SourceRef;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizePath(value: string): string {
|
|
28
|
+
if (value === "*") return "*";
|
|
29
|
+
const withSlash = value.startsWith("/") ? value : "/" + value;
|
|
30
|
+
const collapsed = withSlash.replace(/\/{2,}/g, "/");
|
|
31
|
+
return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function joinPath(parent: string | null, child: string): string {
|
|
35
|
+
if (child.startsWith("/") || child === "*") return normalizePath(child);
|
|
36
|
+
const base = parent === null || parent === "/" ? "" : parent.replace(/\/+$/, "");
|
|
37
|
+
return normalizePath(base + "/" + child);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stringAttribute(
|
|
41
|
+
node: AstNode,
|
|
42
|
+
name: string,
|
|
43
|
+
graph: ModuleGraph,
|
|
44
|
+
file: ProjectFile,
|
|
45
|
+
): string | null {
|
|
46
|
+
const value = jsxAttributeValue(jsxAttribute(node, name));
|
|
47
|
+
if (value === undefined) return null;
|
|
48
|
+
const evaluated = evaluateStatic(value, { graph, file: file.path });
|
|
49
|
+
return evaluated.ok && typeof evaluated.value === "string" ? evaluated.value : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function importedLocals(file: ProjectFile, imported: "Routes" | "Route"): ReadonlySet<string> {
|
|
53
|
+
const names = new Set<string>();
|
|
54
|
+
for (const entry of file.module.imports) {
|
|
55
|
+
if (entry.specifier !== "react-router-dom") continue;
|
|
56
|
+
if ((entry.imported ?? "default") !== imported) continue;
|
|
57
|
+
names.add(entry.local);
|
|
58
|
+
}
|
|
59
|
+
return names;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function findRoutesElement(file: ProjectFile, locals: ReadonlySet<string>): AstNode | null {
|
|
63
|
+
let found: AstNode | null = null;
|
|
64
|
+
walk(file.program, (node) => {
|
|
65
|
+
if (found !== null || node.type !== "JSXElement") return;
|
|
66
|
+
const name = elementName(node);
|
|
67
|
+
if (name !== null && locals.has(name)) found = node;
|
|
68
|
+
});
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read the route table from the project's app file (empty when absent or unreadable). */
|
|
73
|
+
export function buildRouteTable(graph: ModuleGraph, file: ProjectFile): readonly RouteEntry[] {
|
|
74
|
+
const routesLocals = importedLocals(file, "Routes");
|
|
75
|
+
const routeLocals = importedLocals(file, "Route");
|
|
76
|
+
if (routesLocals.size === 0 || routeLocals.size === 0) return [];
|
|
77
|
+
const routes = findRoutesElement(file, routesLocals);
|
|
78
|
+
if (routes === null) return [];
|
|
79
|
+
const entries: RouteEntry[] = [];
|
|
80
|
+
|
|
81
|
+
const parseRoute = (
|
|
82
|
+
node: AstNode,
|
|
83
|
+
parentIndex: number | null,
|
|
84
|
+
parentPath: string | null,
|
|
85
|
+
): void => {
|
|
86
|
+
const pathValue = stringAttribute(node, "path", graph, file);
|
|
87
|
+
const isIndex = jsxAttribute(node, "index") !== undefined;
|
|
88
|
+
const path = pathValue === null ? (parentPath ?? "/") : joinPath(parentPath, pathValue);
|
|
89
|
+
const finalPath = isIndex ? (parentPath ?? "/") : path;
|
|
90
|
+
const element = asNode(jsxAttributeValue(jsxAttribute(node, "element")));
|
|
91
|
+
const index = entries.length;
|
|
92
|
+
entries.push({
|
|
93
|
+
path: finalPath,
|
|
94
|
+
element: element !== undefined && element.type === "JSXElement" ? element : null,
|
|
95
|
+
parentIndex,
|
|
96
|
+
source: sourceRef(file.path, file.source, typeof node.start === "number" ? node.start : 0),
|
|
97
|
+
});
|
|
98
|
+
for (const child of jsxChildren(node)) {
|
|
99
|
+
if (child.type === "JSXElement" && routeLocals.has(elementName(child) ?? "")) {
|
|
100
|
+
parseRoute(child, index, finalPath);
|
|
101
|
+
} else if (child.type === "JSXFragment") {
|
|
102
|
+
for (const nested of jsxChildren(child)) {
|
|
103
|
+
if (nested.type === "JSXElement" && routeLocals.has(elementName(nested) ?? "")) {
|
|
104
|
+
parseRoute(nested, index, finalPath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (const child of jsxChildren(routes)) {
|
|
112
|
+
if (child.type === "JSXElement" && routeLocals.has(elementName(child) ?? "")) {
|
|
113
|
+
parseRoute(child, null, null);
|
|
114
|
+
} else if (child.type === "JSXFragment") {
|
|
115
|
+
for (const nested of jsxChildren(child)) {
|
|
116
|
+
if (nested.type === "JSXElement" && routeLocals.has(elementName(nested) ?? "")) {
|
|
117
|
+
parseRoute(nested, null, null);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return entries;
|
|
123
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { warning } from "../diagnostics.ts";
|
|
2
|
+
import type { Diagnostic, RouteFact, TableFact } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* D-137: a route cannot declare a **system-only** table in its wrapper config. By D-136 that
|
|
6
|
+
* table has no privilege for `facilis_app`, so a query on the route's `ctx.db` fails closed
|
|
7
|
+
* with `permission denied`; the only legal access is the audited `ctx.sudo`/`ctx.system`
|
|
8
|
+
* elevation. A table that appears among the route's elevation `tables` is therefore excluded,
|
|
9
|
+
* and the diagnostic is a warning, not a gate error: an elevation whose table list is dynamic
|
|
10
|
+
* (`tables: loadTables()`) is recorded as unknown, and a warning must not fail that route's CI.
|
|
11
|
+
*/
|
|
12
|
+
export function routeSystemTableDiagnostics(
|
|
13
|
+
routes: readonly RouteFact[],
|
|
14
|
+
tables: readonly TableFact[],
|
|
15
|
+
): readonly Diagnostic[] {
|
|
16
|
+
const byName = new Map<string, TableFact>();
|
|
17
|
+
for (const table of tables) {
|
|
18
|
+
if (!byName.has(table.name)) byName.set(table.name, table);
|
|
19
|
+
}
|
|
20
|
+
const diagnostics: Diagnostic[] = [];
|
|
21
|
+
for (const route of routes) {
|
|
22
|
+
const declared = declaredTables(route);
|
|
23
|
+
if (declared.size === 0) continue;
|
|
24
|
+
const elevated = new Set(route.elevations.flatMap((elevation) => elevation.tables));
|
|
25
|
+
for (const name of declared) {
|
|
26
|
+
const table = byName.get(name);
|
|
27
|
+
if (table === undefined || table.rlsEnabled || elevated.has(name)) continue;
|
|
28
|
+
diagnostics.push(
|
|
29
|
+
warning(
|
|
30
|
+
"route-system-table",
|
|
31
|
+
'table "' +
|
|
32
|
+
name +
|
|
33
|
+
'" is system-only (no RLS); reach it through ctx.sudo/ctx.system, not the route db',
|
|
34
|
+
route.source,
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return diagnostics;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every table the wrapper config names: `dbTables` plus the table side of `dbColumns`. */
|
|
43
|
+
function declaredTables(route: RouteFact): ReadonlySet<string> {
|
|
44
|
+
const names = new Set(route.dbTables ?? []);
|
|
45
|
+
for (const entry of route.dbColumns ?? []) {
|
|
46
|
+
const separator = entry.indexOf(".");
|
|
47
|
+
if (separator > 0) names.add(entry.slice(0, separator));
|
|
48
|
+
}
|
|
49
|
+
return names;
|
|
50
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route files -> RouteFacts (docs/findings/ast-dsl-contract.md §3.1).
|
|
3
|
+
*
|
|
4
|
+
* A route is listed even when it does not use the wrapper: the graph is also the integrity
|
|
5
|
+
* view, so an invalid route is reported instead of omitted. The filename is the only source
|
|
6
|
+
* of method/path (Nitro's file router); the default export is the only source of the config.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { identifierName, unwrapExpression, type AstNode } from "../ast/node.ts";
|
|
10
|
+
import { error, info, warning } from "../diagnostics.ts";
|
|
11
|
+
import { defaultExportDeclaration } from "../module-info.ts";
|
|
12
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
13
|
+
import { sourceRef } from "../source.ts";
|
|
14
|
+
import { EMPTY_ROUTE_RELATIONS } from "../types-relations.ts";
|
|
15
|
+
import type { ConfigFacts, Diagnostic, RouteFact, WrapperState } from "../types.ts";
|
|
16
|
+
import { routeConfigFacts } from "./route-config.ts";
|
|
17
|
+
import { scanElevations } from "./route-elevation.ts";
|
|
18
|
+
import { directDbWarnings, handlerReadsBody, handlerReadsForm } from "./route-handler.ts";
|
|
19
|
+
import { routeLocationFromFile } from "./route-location.ts";
|
|
20
|
+
import {
|
|
21
|
+
defaultExportExpression,
|
|
22
|
+
isLocallyDeclared,
|
|
23
|
+
relativePath,
|
|
24
|
+
sortDiagnostics,
|
|
25
|
+
} from "./static-read.ts";
|
|
26
|
+
|
|
27
|
+
const CORE_SPECIFIER = "@facilis.dev/core";
|
|
28
|
+
const ROUTE_DIRS = ["server/routes/", "server/api/"];
|
|
29
|
+
|
|
30
|
+
export interface RouteResult {
|
|
31
|
+
readonly routes: readonly RouteFact[];
|
|
32
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface WrapperResolution {
|
|
36
|
+
readonly state: WrapperState;
|
|
37
|
+
readonly call: AstNode | null;
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The default export must be a `defineAuthorizedHandler(...)` call of the core import. */
|
|
42
|
+
function resolveWrapper(file: ProjectFile): WrapperResolution {
|
|
43
|
+
const expression = defaultExportExpression(file);
|
|
44
|
+
if (expression === undefined) {
|
|
45
|
+
return { state: "invalid", call: null, reason: "the route file has no default export" };
|
|
46
|
+
}
|
|
47
|
+
if (expression.type !== "CallExpression") {
|
|
48
|
+
return {
|
|
49
|
+
state: "invalid",
|
|
50
|
+
call: null,
|
|
51
|
+
reason: "the default export is not a defineAuthorizedHandler() call",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const callee = unwrapExpression(expression.callee);
|
|
55
|
+
const name = callee?.type === "Identifier" ? identifierName(callee) : undefined;
|
|
56
|
+
if (name === undefined) {
|
|
57
|
+
return { state: "unknown", call: null, reason: "the handler callee cannot be resolved" };
|
|
58
|
+
}
|
|
59
|
+
if (isLocallyDeclared(file, name)) {
|
|
60
|
+
return {
|
|
61
|
+
state: "invalid",
|
|
62
|
+
call: null,
|
|
63
|
+
reason: '"' + name + '" is shadowed by a local declaration',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const imported = file.module.imports.find((entry) => entry.local === name);
|
|
67
|
+
if (
|
|
68
|
+
imported === undefined ||
|
|
69
|
+
imported.specifier !== CORE_SPECIFIER ||
|
|
70
|
+
imported.imported !== "defineAuthorizedHandler"
|
|
71
|
+
) {
|
|
72
|
+
return {
|
|
73
|
+
state: "invalid",
|
|
74
|
+
call: null,
|
|
75
|
+
reason: "the default export is not the defineAuthorizedHandler import from @facilis.dev/core",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { state: "valid", call: expression, reason: "" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function scanRoute(
|
|
82
|
+
graph: ModuleGraph,
|
|
83
|
+
file: ProjectFile,
|
|
84
|
+
config: ConfigFacts,
|
|
85
|
+
): { route: RouteFact | null; diagnostics: readonly Diagnostic[] } {
|
|
86
|
+
const relative = relativePath(graph.root, file.path);
|
|
87
|
+
const dir = ROUTE_DIRS.find((candidate) => relative.startsWith(candidate));
|
|
88
|
+
const declaration = defaultExportDeclaration(file.program);
|
|
89
|
+
const anchor = declaration?.start ?? 0;
|
|
90
|
+
if (dir === undefined) return { route: null, diagnostics: [] };
|
|
91
|
+
const location = routeLocationFromFile(relative.slice(dir.length));
|
|
92
|
+
if (location === undefined) {
|
|
93
|
+
return {
|
|
94
|
+
route: null,
|
|
95
|
+
diagnostics: [
|
|
96
|
+
info(
|
|
97
|
+
"unknown",
|
|
98
|
+
"cannot derive a route method/path from the route file name",
|
|
99
|
+
sourceRef(file.path, file.source, anchor),
|
|
100
|
+
),
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const wrapper = resolveWrapper(file);
|
|
105
|
+
const ref = sourceRef(file.path, file.source, wrapper.call?.start ?? anchor);
|
|
106
|
+
if (wrapper.state !== "valid" || wrapper.call === null) {
|
|
107
|
+
return {
|
|
108
|
+
route: {
|
|
109
|
+
method: location.method,
|
|
110
|
+
path: location.path,
|
|
111
|
+
sourceFile: file.path,
|
|
112
|
+
source: ref,
|
|
113
|
+
wrapper: wrapper.state,
|
|
114
|
+
permission: null,
|
|
115
|
+
crossSite: null,
|
|
116
|
+
dbTables: null,
|
|
117
|
+
dbColumns: null,
|
|
118
|
+
rawSql: null,
|
|
119
|
+
relations: EMPTY_ROUTE_RELATIONS,
|
|
120
|
+
description: null,
|
|
121
|
+
input: { body: false, form: false, query: false, params: false },
|
|
122
|
+
handlerBodyRead: false,
|
|
123
|
+
handlerFormRead: false,
|
|
124
|
+
elevations: [],
|
|
125
|
+
unknown: [{ code: "route-wrapper", reason: wrapper.reason, source: ref }],
|
|
126
|
+
},
|
|
127
|
+
diagnostics: [error("route-wrapper", wrapper.reason, ref)],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const facts = routeConfigFacts(graph, file, wrapper.call, config);
|
|
131
|
+
const elevation = scanElevations(graph, file, wrapper.call);
|
|
132
|
+
return {
|
|
133
|
+
route: {
|
|
134
|
+
method: location.method,
|
|
135
|
+
path: location.path,
|
|
136
|
+
sourceFile: file.path,
|
|
137
|
+
source: ref,
|
|
138
|
+
wrapper: "valid",
|
|
139
|
+
permission: facts.permission,
|
|
140
|
+
crossSite: facts.crossSite,
|
|
141
|
+
dbTables: facts.dbTables,
|
|
142
|
+
dbColumns: facts.dbColumns,
|
|
143
|
+
rawSql: facts.rawSql,
|
|
144
|
+
relations: EMPTY_ROUTE_RELATIONS,
|
|
145
|
+
description: facts.description,
|
|
146
|
+
input: facts.input,
|
|
147
|
+
handlerBodyRead: handlerReadsBody(wrapper.call),
|
|
148
|
+
handlerFormRead: handlerReadsForm(wrapper.call),
|
|
149
|
+
elevations: elevation.facts,
|
|
150
|
+
unknown: [...facts.unknown, ...elevation.unknown],
|
|
151
|
+
},
|
|
152
|
+
diagnostics: [
|
|
153
|
+
...facts.diagnostics,
|
|
154
|
+
...elevation.unknown.map((item) =>
|
|
155
|
+
// An unreadable elevation cannot be shown in the graph; warn without blocking the route.
|
|
156
|
+
warning(item.code, item.reason, item.source),
|
|
157
|
+
),
|
|
158
|
+
...directDbWarnings(file, ref),
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The `defineAuthorizedHandler(...)` call of a valid wrapper, for later passes (D-157). */
|
|
164
|
+
export function resolveWrapperCall(file: ProjectFile): AstNode | null {
|
|
165
|
+
const wrapper = resolveWrapper(file);
|
|
166
|
+
return wrapper.state === "valid" ? wrapper.call : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Read `server/routes/**` and `server/api/**` into route facts. */
|
|
170
|
+
export function buildRouteFacts(graph: ModuleGraph, config: ConfigFacts): RouteResult {
|
|
171
|
+
const files = [...graph.files.values()]
|
|
172
|
+
.filter((file) => ROUTE_DIRS.some((dir) => relativePath(graph.root, file.path).startsWith(dir)))
|
|
173
|
+
.toSorted((left, right) => left.path.localeCompare(right.path));
|
|
174
|
+
const routes: RouteFact[] = [];
|
|
175
|
+
const diagnostics: Diagnostic[] = [];
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
const scanned = scanRoute(graph, file, config);
|
|
178
|
+
if (scanned.route !== null) routes.push(scanned.route);
|
|
179
|
+
diagnostics.push(...scanned.diagnostics);
|
|
180
|
+
}
|
|
181
|
+
routes.sort(
|
|
182
|
+
(left, right) =>
|
|
183
|
+
left.path.localeCompare(right.path) ||
|
|
184
|
+
left.method.localeCompare(right.method) ||
|
|
185
|
+
left.sourceFile.localeCompare(right.sourceFile),
|
|
186
|
+
);
|
|
187
|
+
return { routes, diagnostics: sortDiagnostics(diagnostics) };
|
|
188
|
+
}
|