@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,199 @@
|
|
|
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
|
+
import { asNode, callArguments, identifierName, memberName, nodeArray, objectEntries, propertyName, propertyValue, unwrapExpression, } from "../ast/node.js";
|
|
9
|
+
import { sourceRef } from "../source.js";
|
|
10
|
+
import { followFunction, handleElevation } from "./route-relations-follow.js";
|
|
11
|
+
import { createUsage, freezeUsage, addUnresolved, addUse, markOpaque, } from "./route-relations-usage.js";
|
|
12
|
+
import { carriesClient, chainClient, clientOf, resolveLocalFunction, ROUTE_CLIENT, } from "./route-relation-resolve.js";
|
|
13
|
+
const MAX_DEPTH = 6;
|
|
14
|
+
const RELATION_METHODS = new Set(["from", "innerJoin", "leftJoin", "rightJoin", "fullJoin"]);
|
|
15
|
+
const WRITE_METHODS = new Set(["insert", "update", "delete"]);
|
|
16
|
+
const SKIPPED_KEYS = new Set(["type", "start", "end", "range"]);
|
|
17
|
+
const ROUTE_SEED = { contextParam: 1, elevations: true };
|
|
18
|
+
function elevationKind(name) {
|
|
19
|
+
return name === "sudo" || name === "system" ? name : null;
|
|
20
|
+
}
|
|
21
|
+
function refOf(scope, node) {
|
|
22
|
+
return sourceRef(scope.file.path, scope.file.source, typeof node.start === "number" ? node.start : 0);
|
|
23
|
+
}
|
|
24
|
+
/** A callee that is `ctx.sudo`/`ctx.system` or a destructured/forwarded elevation handler. */
|
|
25
|
+
function calleeElevationKind(callee, scope) {
|
|
26
|
+
if (!scope.elevations)
|
|
27
|
+
return null;
|
|
28
|
+
const expression = unwrapExpression(callee);
|
|
29
|
+
if (expression === undefined)
|
|
30
|
+
return null;
|
|
31
|
+
if (expression.type === "Identifier") {
|
|
32
|
+
return scope.elevationFns.get(identifierName(expression) ?? "") ?? null;
|
|
33
|
+
}
|
|
34
|
+
if (expression.type !== "MemberExpression")
|
|
35
|
+
return null;
|
|
36
|
+
const kind = elevationKind(memberName(expression));
|
|
37
|
+
if (kind === null)
|
|
38
|
+
return null;
|
|
39
|
+
const object = unwrapExpression(expression.object);
|
|
40
|
+
return object?.type === "Identifier" && identifierName(object) === scope.contextName
|
|
41
|
+
? kind
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
44
|
+
/** `db.query.<relation>`: a static property name the runtime guard resolves to a relation. */
|
|
45
|
+
function trackQueryMember(node, scope) {
|
|
46
|
+
const property = memberName(node);
|
|
47
|
+
const object = unwrapExpression(node.object);
|
|
48
|
+
if (property === undefined || object?.type !== "MemberExpression")
|
|
49
|
+
return;
|
|
50
|
+
if (memberName(object) !== "query")
|
|
51
|
+
return;
|
|
52
|
+
if (clientOf(object.object, scope.env, scope.contextName) === null)
|
|
53
|
+
return;
|
|
54
|
+
if (scope.index.names.has(property))
|
|
55
|
+
addUse(scope, property, refOf(scope, node));
|
|
56
|
+
else
|
|
57
|
+
addUnresolved(scope, refOf(scope, node));
|
|
58
|
+
}
|
|
59
|
+
/** `const scoped = ctx.db` / `const rows = todos`: aliases the walk keeps resolving. */
|
|
60
|
+
function trackDeclaration(node, scope) {
|
|
61
|
+
if (node.type !== "VariableDeclarator")
|
|
62
|
+
return;
|
|
63
|
+
const name = identifierName(node.id);
|
|
64
|
+
if (name === undefined)
|
|
65
|
+
return;
|
|
66
|
+
const client = clientOf(node.init, scope.env, scope.contextName);
|
|
67
|
+
if (client !== null) {
|
|
68
|
+
scope.env.set(name, client);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const resolution = scope.index.resolve(scope.graph, scope.file, asNode(node.init), scope.aliases);
|
|
72
|
+
if (resolution.kind === "resolved")
|
|
73
|
+
scope.aliases.set(name, resolution.name);
|
|
74
|
+
}
|
|
75
|
+
function recordRelation(scope, node) {
|
|
76
|
+
if (node === undefined)
|
|
77
|
+
return;
|
|
78
|
+
const resolution = scope.index.resolve(scope.graph, scope.file, node, scope.aliases);
|
|
79
|
+
if (resolution.kind === "resolved")
|
|
80
|
+
addUse(scope, resolution.name, refOf(scope, node));
|
|
81
|
+
else if (resolution.kind === "unresolved")
|
|
82
|
+
addUnresolved(scope, refOf(scope, node));
|
|
83
|
+
else
|
|
84
|
+
markOpaque(scope);
|
|
85
|
+
}
|
|
86
|
+
function handleCall(node, scope) {
|
|
87
|
+
const callee = unwrapExpression(node.callee);
|
|
88
|
+
const kind = calleeElevationKind(callee, scope);
|
|
89
|
+
if (kind !== null)
|
|
90
|
+
return handleElevation(visit, node, scope, kind);
|
|
91
|
+
if (callee?.type === "MemberExpression") {
|
|
92
|
+
const name = memberName(callee);
|
|
93
|
+
const object = unwrapExpression(callee.object);
|
|
94
|
+
if (name === "execute" && chainClient(object, scope.env, scope.contextName) !== null) {
|
|
95
|
+
scope.usage.rawSql.push(refOf(scope, node));
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (name !== undefined &&
|
|
99
|
+
RELATION_METHODS.has(name) &&
|
|
100
|
+
chainClient(object, scope.env, scope.contextName) !== null) {
|
|
101
|
+
recordRelation(scope, callArguments(node)[0]);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (name !== undefined &&
|
|
105
|
+
WRITE_METHODS.has(name) &&
|
|
106
|
+
clientOf(object, scope.env, scope.contextName) !== null) {
|
|
107
|
+
recordRelation(scope, callArguments(node)[0]);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const local = resolveLocalFunction(scope.graph, scope.file, callee);
|
|
112
|
+
if (local !== null) {
|
|
113
|
+
followFunction(visit, node, scope, local);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
// A project-external callee that receives the client makes the usage opaque: the runtime
|
|
117
|
+
// guard is the authority, so the pass must not raise a false undeclared/unused error.
|
|
118
|
+
if (callArguments(node).some((argument) => carriesClient(argument, scope.env, scope.contextName))) {
|
|
119
|
+
markOpaque(scope);
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
function visit(node, scope) {
|
|
124
|
+
if (scope.depth > MAX_DEPTH)
|
|
125
|
+
return;
|
|
126
|
+
trackDeclaration(node, scope);
|
|
127
|
+
if (node.type === "MemberExpression")
|
|
128
|
+
trackQueryMember(node, scope);
|
|
129
|
+
if (node.type === "CallExpression" && handleCall(node, scope))
|
|
130
|
+
return;
|
|
131
|
+
for (const key of Object.keys(node)) {
|
|
132
|
+
if (SKIPPED_KEYS.has(key))
|
|
133
|
+
continue;
|
|
134
|
+
const value = node[key];
|
|
135
|
+
if (Array.isArray(value)) {
|
|
136
|
+
for (const child of value)
|
|
137
|
+
if (asNode(child) !== undefined)
|
|
138
|
+
visit(asNode(child), scope);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const child = asNode(value);
|
|
142
|
+
if (child !== undefined)
|
|
143
|
+
visit(child, scope);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function functionScope(graph, file, fn, index, usage, seed) {
|
|
147
|
+
const env = new Map();
|
|
148
|
+
const elevationFns = new Map();
|
|
149
|
+
let contextName = null;
|
|
150
|
+
const context = unwrapExpression(nodeArray(fn, "params")[seed.contextParam]);
|
|
151
|
+
if (context?.type === "Identifier")
|
|
152
|
+
contextName = identifierName(context) ?? null;
|
|
153
|
+
else if (context?.type === "ObjectPattern") {
|
|
154
|
+
for (const entry of objectEntries(context)) {
|
|
155
|
+
if (entry.type !== "Property")
|
|
156
|
+
continue;
|
|
157
|
+
const name = propertyName(entry);
|
|
158
|
+
let value = propertyValue(entry);
|
|
159
|
+
if (value?.type === "AssignmentPattern")
|
|
160
|
+
value = unwrapExpression(value.left);
|
|
161
|
+
const local = identifierName(unwrapExpression(value));
|
|
162
|
+
if (local === undefined)
|
|
163
|
+
continue;
|
|
164
|
+
if (name === "db")
|
|
165
|
+
env.set(local, ROUTE_CLIENT);
|
|
166
|
+
else if (seed.elevations) {
|
|
167
|
+
const kind = elevationKind(name);
|
|
168
|
+
if (kind !== null)
|
|
169
|
+
elevationFns.set(local, kind);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
graph,
|
|
175
|
+
index,
|
|
176
|
+
file,
|
|
177
|
+
env,
|
|
178
|
+
aliases: new Map(),
|
|
179
|
+
elevationFns,
|
|
180
|
+
contextName,
|
|
181
|
+
elevations: seed.elevations,
|
|
182
|
+
elevation: null,
|
|
183
|
+
depth: 0,
|
|
184
|
+
stack: new Set(),
|
|
185
|
+
usage,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Scan one route handler or job run function into its relation-usage fact. */
|
|
189
|
+
export function scanFunctionUsage(graph, file, fn, index, seed) {
|
|
190
|
+
const body = asNode(fn.body);
|
|
191
|
+
const usage = createUsage();
|
|
192
|
+
if (body !== undefined)
|
|
193
|
+
visit(body, functionScope(graph, file, fn, index, usage, seed));
|
|
194
|
+
return freezeUsage(usage);
|
|
195
|
+
}
|
|
196
|
+
/** Scan one valid route handler into its relation-usage fact. */
|
|
197
|
+
export function scanHandlerUsage(graph, file, handler, index) {
|
|
198
|
+
return scanFunctionUsage(graph, file, handler, index, ROUTE_SEED);
|
|
199
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
import type { ModuleGraph } from "../module-graph.js";
|
|
11
|
+
import type { RouteFact, TableFact, ViewFact, Diagnostic } from "../types.js";
|
|
12
|
+
export interface RouteRelationsScan {
|
|
13
|
+
readonly routes: readonly RouteFact[];
|
|
14
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
15
|
+
}
|
|
16
|
+
/** Attach relation-usage facts to every scannable route and derive the D-157 diagnostics. */
|
|
17
|
+
export declare function buildRouteRelationFacts(graph: ModuleGraph, routes: readonly RouteFact[], tables: readonly TableFact[], views: readonly ViewFact[]): RouteRelationsScan;
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
import { callArguments, unwrapExpression } from "../ast/node.js";
|
|
11
|
+
import { EMPTY_ROUTE_RELATIONS } from "../types-relations.js";
|
|
12
|
+
import { createRelationIndex } from "./route-relation-resolve.js";
|
|
13
|
+
import { routeRelationDiagnostics } from "./route-relations-diagnostics.js";
|
|
14
|
+
import { scanHandlerUsage } from "./route-relations-walk.js";
|
|
15
|
+
import { resolveWrapperCall } from "./routes.js";
|
|
16
|
+
function handlerOf(call) {
|
|
17
|
+
if (call === null)
|
|
18
|
+
return undefined;
|
|
19
|
+
return unwrapExpression(callArguments(call)[1]);
|
|
20
|
+
}
|
|
21
|
+
/** Attach relation-usage facts to every scannable route and derive the D-157 diagnostics. */
|
|
22
|
+
export function buildRouteRelationFacts(graph, routes, tables, views) {
|
|
23
|
+
const index = createRelationIndex(tables, views);
|
|
24
|
+
const enriched = [];
|
|
25
|
+
const diagnostics = [];
|
|
26
|
+
for (const route of routes) {
|
|
27
|
+
const file = graph.file(route.sourceFile);
|
|
28
|
+
const handler = file === undefined || route.wrapper !== "valid"
|
|
29
|
+
? undefined
|
|
30
|
+
: handlerOf(resolveWrapperCall(file));
|
|
31
|
+
if (file === undefined ||
|
|
32
|
+
handler === undefined ||
|
|
33
|
+
(handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression")) {
|
|
34
|
+
const relations = EMPTY_ROUTE_RELATIONS;
|
|
35
|
+
enriched.push({ ...route, relations });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const relations = scanHandlerUsage(graph, file, handler, index);
|
|
39
|
+
enriched.push({ ...route, relations });
|
|
40
|
+
diagnostics.push(...routeRelationDiagnostics(route, relations));
|
|
41
|
+
}
|
|
42
|
+
return { routes: enriched, diagnostics };
|
|
43
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
import { type AstNode } from "../ast/node.js";
|
|
9
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.js";
|
|
10
|
+
import type { SourceRef } from "../types.js";
|
|
11
|
+
export interface RouteEntry {
|
|
12
|
+
/** Normalized URL path; "*" for a catch-all route. */
|
|
13
|
+
readonly path: string;
|
|
14
|
+
/** The route's element JSX, when it is a JSX element rather than a component reference. */
|
|
15
|
+
readonly element: AstNode | null;
|
|
16
|
+
/** Index of the parent route entry (pathless layout routes are entries too). */
|
|
17
|
+
readonly parentIndex: number | null;
|
|
18
|
+
readonly source: SourceRef;
|
|
19
|
+
}
|
|
20
|
+
/** Read the route table from the project's app file (empty when absent or unreadable). */
|
|
21
|
+
export declare function buildRouteTable(graph: ModuleGraph, file: ProjectFile): readonly RouteEntry[];
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
import { asNode } from "../ast/node.js";
|
|
9
|
+
import { walk } from "../ast/walk.js";
|
|
10
|
+
import { sourceRef } from "../source.js";
|
|
11
|
+
import { evaluateStatic } from "../static-eval.js";
|
|
12
|
+
import { elementName, jsxAttribute, jsxAttributeValue, jsxChildren } from "./jsx.js";
|
|
13
|
+
function normalizePath(value) {
|
|
14
|
+
if (value === "*")
|
|
15
|
+
return "*";
|
|
16
|
+
const withSlash = value.startsWith("/") ? value : "/" + value;
|
|
17
|
+
const collapsed = withSlash.replace(/\/{2,}/g, "/");
|
|
18
|
+
return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
|
|
19
|
+
}
|
|
20
|
+
function joinPath(parent, child) {
|
|
21
|
+
if (child.startsWith("/") || child === "*")
|
|
22
|
+
return normalizePath(child);
|
|
23
|
+
const base = parent === null || parent === "/" ? "" : parent.replace(/\/+$/, "");
|
|
24
|
+
return normalizePath(base + "/" + child);
|
|
25
|
+
}
|
|
26
|
+
function stringAttribute(node, name, graph, file) {
|
|
27
|
+
const value = jsxAttributeValue(jsxAttribute(node, name));
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return null;
|
|
30
|
+
const evaluated = evaluateStatic(value, { graph, file: file.path });
|
|
31
|
+
return evaluated.ok && typeof evaluated.value === "string" ? evaluated.value : null;
|
|
32
|
+
}
|
|
33
|
+
function importedLocals(file, imported) {
|
|
34
|
+
const names = new Set();
|
|
35
|
+
for (const entry of file.module.imports) {
|
|
36
|
+
if (entry.specifier !== "react-router-dom")
|
|
37
|
+
continue;
|
|
38
|
+
if ((entry.imported ?? "default") !== imported)
|
|
39
|
+
continue;
|
|
40
|
+
names.add(entry.local);
|
|
41
|
+
}
|
|
42
|
+
return names;
|
|
43
|
+
}
|
|
44
|
+
function findRoutesElement(file, locals) {
|
|
45
|
+
let found = null;
|
|
46
|
+
walk(file.program, (node) => {
|
|
47
|
+
if (found !== null || node.type !== "JSXElement")
|
|
48
|
+
return;
|
|
49
|
+
const name = elementName(node);
|
|
50
|
+
if (name !== null && locals.has(name))
|
|
51
|
+
found = node;
|
|
52
|
+
});
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
/** Read the route table from the project's app file (empty when absent or unreadable). */
|
|
56
|
+
export function buildRouteTable(graph, file) {
|
|
57
|
+
const routesLocals = importedLocals(file, "Routes");
|
|
58
|
+
const routeLocals = importedLocals(file, "Route");
|
|
59
|
+
if (routesLocals.size === 0 || routeLocals.size === 0)
|
|
60
|
+
return [];
|
|
61
|
+
const routes = findRoutesElement(file, routesLocals);
|
|
62
|
+
if (routes === null)
|
|
63
|
+
return [];
|
|
64
|
+
const entries = [];
|
|
65
|
+
const parseRoute = (node, parentIndex, parentPath) => {
|
|
66
|
+
const pathValue = stringAttribute(node, "path", graph, file);
|
|
67
|
+
const isIndex = jsxAttribute(node, "index") !== undefined;
|
|
68
|
+
const path = pathValue === null ? (parentPath ?? "/") : joinPath(parentPath, pathValue);
|
|
69
|
+
const finalPath = isIndex ? (parentPath ?? "/") : path;
|
|
70
|
+
const element = asNode(jsxAttributeValue(jsxAttribute(node, "element")));
|
|
71
|
+
const index = entries.length;
|
|
72
|
+
entries.push({
|
|
73
|
+
path: finalPath,
|
|
74
|
+
element: element !== undefined && element.type === "JSXElement" ? element : null,
|
|
75
|
+
parentIndex,
|
|
76
|
+
source: sourceRef(file.path, file.source, typeof node.start === "number" ? node.start : 0),
|
|
77
|
+
});
|
|
78
|
+
for (const child of jsxChildren(node)) {
|
|
79
|
+
if (child.type === "JSXElement" && routeLocals.has(elementName(child) ?? "")) {
|
|
80
|
+
parseRoute(child, index, finalPath);
|
|
81
|
+
}
|
|
82
|
+
else if (child.type === "JSXFragment") {
|
|
83
|
+
for (const nested of jsxChildren(child)) {
|
|
84
|
+
if (nested.type === "JSXElement" && routeLocals.has(elementName(nested) ?? "")) {
|
|
85
|
+
parseRoute(nested, index, finalPath);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
for (const child of jsxChildren(routes)) {
|
|
92
|
+
if (child.type === "JSXElement" && routeLocals.has(elementName(child) ?? "")) {
|
|
93
|
+
parseRoute(child, null, null);
|
|
94
|
+
}
|
|
95
|
+
else if (child.type === "JSXFragment") {
|
|
96
|
+
for (const nested of jsxChildren(child)) {
|
|
97
|
+
if (nested.type === "JSXElement" && routeLocals.has(elementName(nested) ?? "")) {
|
|
98
|
+
parseRoute(nested, null, null);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return entries;
|
|
104
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Diagnostic, RouteFact, TableFact } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* D-137: a route cannot declare a **system-only** table in its wrapper config. By D-136 that
|
|
4
|
+
* table has no privilege for `facilis_app`, so a query on the route's `ctx.db` fails closed
|
|
5
|
+
* with `permission denied`; the only legal access is the audited `ctx.sudo`/`ctx.system`
|
|
6
|
+
* elevation. A table that appears among the route's elevation `tables` is therefore excluded,
|
|
7
|
+
* and the diagnostic is a warning, not a gate error: an elevation whose table list is dynamic
|
|
8
|
+
* (`tables: loadTables()`) is recorded as unknown, and a warning must not fail that route's CI.
|
|
9
|
+
*/
|
|
10
|
+
export declare function routeSystemTableDiagnostics(routes: readonly RouteFact[], tables: readonly TableFact[]): readonly Diagnostic[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { warning } from "../diagnostics.js";
|
|
2
|
+
/**
|
|
3
|
+
* D-137: a route cannot declare a **system-only** table in its wrapper config. By D-136 that
|
|
4
|
+
* table has no privilege for `facilis_app`, so a query on the route's `ctx.db` fails closed
|
|
5
|
+
* with `permission denied`; the only legal access is the audited `ctx.sudo`/`ctx.system`
|
|
6
|
+
* elevation. A table that appears among the route's elevation `tables` is therefore excluded,
|
|
7
|
+
* and the diagnostic is a warning, not a gate error: an elevation whose table list is dynamic
|
|
8
|
+
* (`tables: loadTables()`) is recorded as unknown, and a warning must not fail that route's CI.
|
|
9
|
+
*/
|
|
10
|
+
export function routeSystemTableDiagnostics(routes, tables) {
|
|
11
|
+
const byName = new Map();
|
|
12
|
+
for (const table of tables) {
|
|
13
|
+
if (!byName.has(table.name))
|
|
14
|
+
byName.set(table.name, table);
|
|
15
|
+
}
|
|
16
|
+
const diagnostics = [];
|
|
17
|
+
for (const route of routes) {
|
|
18
|
+
const declared = declaredTables(route);
|
|
19
|
+
if (declared.size === 0)
|
|
20
|
+
continue;
|
|
21
|
+
const elevated = new Set(route.elevations.flatMap((elevation) => elevation.tables));
|
|
22
|
+
for (const name of declared) {
|
|
23
|
+
const table = byName.get(name);
|
|
24
|
+
if (table === undefined || table.rlsEnabled || elevated.has(name))
|
|
25
|
+
continue;
|
|
26
|
+
diagnostics.push(warning("route-system-table", 'table "' +
|
|
27
|
+
name +
|
|
28
|
+
'" is system-only (no RLS); reach it through ctx.sudo/ctx.system, not the route db', route.source));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return diagnostics;
|
|
32
|
+
}
|
|
33
|
+
/** Every table the wrapper config names: `dbTables` plus the table side of `dbColumns`. */
|
|
34
|
+
function declaredTables(route) {
|
|
35
|
+
const names = new Set(route.dbTables ?? []);
|
|
36
|
+
for (const entry of route.dbColumns ?? []) {
|
|
37
|
+
const separator = entry.indexOf(".");
|
|
38
|
+
if (separator > 0)
|
|
39
|
+
names.add(entry.slice(0, separator));
|
|
40
|
+
}
|
|
41
|
+
return names;
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
import { type AstNode } from "../ast/node.js";
|
|
9
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.js";
|
|
10
|
+
import type { ConfigFacts, Diagnostic, RouteFact } from "../types.js";
|
|
11
|
+
export interface RouteResult {
|
|
12
|
+
readonly routes: readonly RouteFact[];
|
|
13
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
14
|
+
}
|
|
15
|
+
/** The `defineAuthorizedHandler(...)` call of a valid wrapper, for later passes (D-157). */
|
|
16
|
+
export declare function resolveWrapperCall(file: ProjectFile): AstNode | null;
|
|
17
|
+
/** Read `server/routes/**` and `server/api/**` into route facts. */
|
|
18
|
+
export declare function buildRouteFacts(graph: ModuleGraph, config: ConfigFacts): RouteResult;
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
import { identifierName, unwrapExpression } from "../ast/node.js";
|
|
9
|
+
import { error, info, warning } from "../diagnostics.js";
|
|
10
|
+
import { defaultExportDeclaration } from "../module-info.js";
|
|
11
|
+
import { sourceRef } from "../source.js";
|
|
12
|
+
import { EMPTY_ROUTE_RELATIONS } from "../types-relations.js";
|
|
13
|
+
import { routeConfigFacts } from "./route-config.js";
|
|
14
|
+
import { scanElevations } from "./route-elevation.js";
|
|
15
|
+
import { directDbWarnings, handlerReadsBody, handlerReadsForm } from "./route-handler.js";
|
|
16
|
+
import { routeLocationFromFile } from "./route-location.js";
|
|
17
|
+
import { defaultExportExpression, isLocallyDeclared, relativePath, sortDiagnostics, } from "./static-read.js";
|
|
18
|
+
const CORE_SPECIFIER = "@facilis.dev/core";
|
|
19
|
+
const ROUTE_DIRS = ["server/routes/", "server/api/"];
|
|
20
|
+
/** The default export must be a `defineAuthorizedHandler(...)` call of the core import. */
|
|
21
|
+
function resolveWrapper(file) {
|
|
22
|
+
const expression = defaultExportExpression(file);
|
|
23
|
+
if (expression === undefined) {
|
|
24
|
+
return { state: "invalid", call: null, reason: "the route file has no default export" };
|
|
25
|
+
}
|
|
26
|
+
if (expression.type !== "CallExpression") {
|
|
27
|
+
return {
|
|
28
|
+
state: "invalid",
|
|
29
|
+
call: null,
|
|
30
|
+
reason: "the default export is not a defineAuthorizedHandler() call",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const callee = unwrapExpression(expression.callee);
|
|
34
|
+
const name = callee?.type === "Identifier" ? identifierName(callee) : undefined;
|
|
35
|
+
if (name === undefined) {
|
|
36
|
+
return { state: "unknown", call: null, reason: "the handler callee cannot be resolved" };
|
|
37
|
+
}
|
|
38
|
+
if (isLocallyDeclared(file, name)) {
|
|
39
|
+
return {
|
|
40
|
+
state: "invalid",
|
|
41
|
+
call: null,
|
|
42
|
+
reason: '"' + name + '" is shadowed by a local declaration',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const imported = file.module.imports.find((entry) => entry.local === name);
|
|
46
|
+
if (imported === undefined ||
|
|
47
|
+
imported.specifier !== CORE_SPECIFIER ||
|
|
48
|
+
imported.imported !== "defineAuthorizedHandler") {
|
|
49
|
+
return {
|
|
50
|
+
state: "invalid",
|
|
51
|
+
call: null,
|
|
52
|
+
reason: "the default export is not the defineAuthorizedHandler import from @facilis.dev/core",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { state: "valid", call: expression, reason: "" };
|
|
56
|
+
}
|
|
57
|
+
function scanRoute(graph, file, config) {
|
|
58
|
+
const relative = relativePath(graph.root, file.path);
|
|
59
|
+
const dir = ROUTE_DIRS.find((candidate) => relative.startsWith(candidate));
|
|
60
|
+
const declaration = defaultExportDeclaration(file.program);
|
|
61
|
+
const anchor = declaration?.start ?? 0;
|
|
62
|
+
if (dir === undefined)
|
|
63
|
+
return { route: null, diagnostics: [] };
|
|
64
|
+
const location = routeLocationFromFile(relative.slice(dir.length));
|
|
65
|
+
if (location === undefined) {
|
|
66
|
+
return {
|
|
67
|
+
route: null,
|
|
68
|
+
diagnostics: [
|
|
69
|
+
info("unknown", "cannot derive a route method/path from the route file name", sourceRef(file.path, file.source, anchor)),
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const wrapper = resolveWrapper(file);
|
|
74
|
+
const ref = sourceRef(file.path, file.source, wrapper.call?.start ?? anchor);
|
|
75
|
+
if (wrapper.state !== "valid" || wrapper.call === null) {
|
|
76
|
+
return {
|
|
77
|
+
route: {
|
|
78
|
+
method: location.method,
|
|
79
|
+
path: location.path,
|
|
80
|
+
sourceFile: file.path,
|
|
81
|
+
source: ref,
|
|
82
|
+
wrapper: wrapper.state,
|
|
83
|
+
permission: null,
|
|
84
|
+
crossSite: null,
|
|
85
|
+
dbTables: null,
|
|
86
|
+
dbColumns: null,
|
|
87
|
+
rawSql: null,
|
|
88
|
+
relations: EMPTY_ROUTE_RELATIONS,
|
|
89
|
+
description: null,
|
|
90
|
+
input: { body: false, form: false, query: false, params: false },
|
|
91
|
+
handlerBodyRead: false,
|
|
92
|
+
handlerFormRead: false,
|
|
93
|
+
elevations: [],
|
|
94
|
+
unknown: [{ code: "route-wrapper", reason: wrapper.reason, source: ref }],
|
|
95
|
+
},
|
|
96
|
+
diagnostics: [error("route-wrapper", wrapper.reason, ref)],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const facts = routeConfigFacts(graph, file, wrapper.call, config);
|
|
100
|
+
const elevation = scanElevations(graph, file, wrapper.call);
|
|
101
|
+
return {
|
|
102
|
+
route: {
|
|
103
|
+
method: location.method,
|
|
104
|
+
path: location.path,
|
|
105
|
+
sourceFile: file.path,
|
|
106
|
+
source: ref,
|
|
107
|
+
wrapper: "valid",
|
|
108
|
+
permission: facts.permission,
|
|
109
|
+
crossSite: facts.crossSite,
|
|
110
|
+
dbTables: facts.dbTables,
|
|
111
|
+
dbColumns: facts.dbColumns,
|
|
112
|
+
rawSql: facts.rawSql,
|
|
113
|
+
relations: EMPTY_ROUTE_RELATIONS,
|
|
114
|
+
description: facts.description,
|
|
115
|
+
input: facts.input,
|
|
116
|
+
handlerBodyRead: handlerReadsBody(wrapper.call),
|
|
117
|
+
handlerFormRead: handlerReadsForm(wrapper.call),
|
|
118
|
+
elevations: elevation.facts,
|
|
119
|
+
unknown: [...facts.unknown, ...elevation.unknown],
|
|
120
|
+
},
|
|
121
|
+
diagnostics: [
|
|
122
|
+
...facts.diagnostics,
|
|
123
|
+
...elevation.unknown.map((item) =>
|
|
124
|
+
// An unreadable elevation cannot be shown in the graph; warn without blocking the route.
|
|
125
|
+
warning(item.code, item.reason, item.source)),
|
|
126
|
+
...directDbWarnings(file, ref),
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** The `defineAuthorizedHandler(...)` call of a valid wrapper, for later passes (D-157). */
|
|
131
|
+
export function resolveWrapperCall(file) {
|
|
132
|
+
const wrapper = resolveWrapper(file);
|
|
133
|
+
return wrapper.state === "valid" ? wrapper.call : null;
|
|
134
|
+
}
|
|
135
|
+
/** Read `server/routes/**` and `server/api/**` into route facts. */
|
|
136
|
+
export function buildRouteFacts(graph, config) {
|
|
137
|
+
const files = [...graph.files.values()]
|
|
138
|
+
.filter((file) => ROUTE_DIRS.some((dir) => relativePath(graph.root, file.path).startsWith(dir)))
|
|
139
|
+
.toSorted((left, right) => left.path.localeCompare(right.path));
|
|
140
|
+
const routes = [];
|
|
141
|
+
const diagnostics = [];
|
|
142
|
+
for (const file of files) {
|
|
143
|
+
const scanned = scanRoute(graph, file, config);
|
|
144
|
+
if (scanned.route !== null)
|
|
145
|
+
routes.push(scanned.route);
|
|
146
|
+
diagnostics.push(...scanned.diagnostics);
|
|
147
|
+
}
|
|
148
|
+
routes.sort((left, right) => left.path.localeCompare(right.path) ||
|
|
149
|
+
left.method.localeCompare(right.method) ||
|
|
150
|
+
left.sourceFile.localeCompare(right.sourceFile));
|
|
151
|
+
return { routes, diagnostics: sortDiagnostics(diagnostics) };
|
|
152
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AstNode } from "../ast/node.js";
|
|
2
|
+
import { type StaticContext } from "../static-eval.js";
|
|
3
|
+
import type { ColumnDefaultFact } from "../types.js";
|
|
4
|
+
/** Static object argument of a builder/method, e.g. `{ withTimezone: true }`. */
|
|
5
|
+
export declare function optionsOf(node: AstNode | undefined, context: StaticContext): Record<string, unknown> | null;
|
|
6
|
+
/** Display type derived from the builder and its static arguments (never a rendered SQL type). */
|
|
7
|
+
export declare function displayType(name: string, args: readonly AstNode[], context: StaticContext): string;
|
|
8
|
+
/** `.default(value)`: only primitives are facts; a SQL expression is an explicit unknown. */
|
|
9
|
+
export declare function readDefault(node: AstNode | undefined, context: StaticContext): ColumnDefaultFact;
|