@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.
Files changed (204) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/dist/analyze.d.ts +17 -0
  4. package/dist/analyze.js +99 -0
  5. package/dist/ast/node.d.ts +29 -0
  6. package/dist/ast/node.js +85 -0
  7. package/dist/ast/walk.d.ts +8 -0
  8. package/dist/ast/walk.js +40 -0
  9. package/dist/diagnostics.d.ts +5 -0
  10. package/dist/diagnostics.js +12 -0
  11. package/dist/fs.d.ts +13 -0
  12. package/dist/fs.js +51 -0
  13. package/dist/graph-edges.d.ts +3 -0
  14. package/dist/graph-edges.js +148 -0
  15. package/dist/graph-ids.d.ts +6 -0
  16. package/dist/graph-ids.js +22 -0
  17. package/dist/graph.d.ts +15 -0
  18. package/dist/graph.js +162 -0
  19. package/dist/index.d.ts +24 -0
  20. package/dist/index.js +17 -0
  21. package/dist/json.d.ts +8 -0
  22. package/dist/json.js +31 -0
  23. package/dist/module-graph.d.ts +33 -0
  24. package/dist/module-graph.js +86 -0
  25. package/dist/module-info.d.ts +27 -0
  26. package/dist/module-info.js +143 -0
  27. package/dist/node.d.ts +16 -0
  28. package/dist/node.js +41 -0
  29. package/dist/parser.d.ts +16 -0
  30. package/dist/parser.js +1 -0
  31. package/dist/passes/clients.d.ts +22 -0
  32. package/dist/passes/clients.js +167 -0
  33. package/dist/passes/config.d.ts +16 -0
  34. package/dist/passes/config.js +229 -0
  35. package/dist/passes/file-facts.d.ts +13 -0
  36. package/dist/passes/file-facts.js +130 -0
  37. package/dist/passes/i18n.d.ts +14 -0
  38. package/dist/passes/i18n.js +55 -0
  39. package/dist/passes/job-relations-diagnostics.d.ts +9 -0
  40. package/dist/passes/job-relations-diagnostics.js +38 -0
  41. package/dist/passes/job-relations.d.ts +18 -0
  42. package/dist/passes/job-relations.js +52 -0
  43. package/dist/passes/jobs.d.ts +20 -0
  44. package/dist/passes/jobs.js +133 -0
  45. package/dist/passes/jsx-text.d.ts +28 -0
  46. package/dist/passes/jsx-text.js +121 -0
  47. package/dist/passes/jsx.d.ts +24 -0
  48. package/dist/passes/jsx.js +135 -0
  49. package/dist/passes/path-expression.d.ts +26 -0
  50. package/dist/passes/path-expression.js +265 -0
  51. package/dist/passes/route-config.d.ts +24 -0
  52. package/dist/passes/route-config.js +152 -0
  53. package/dist/passes/route-description.d.ts +13 -0
  54. package/dist/passes/route-description.js +23 -0
  55. package/dist/passes/route-elevation.d.ts +18 -0
  56. package/dist/passes/route-elevation.js +136 -0
  57. package/dist/passes/route-handler.d.ts +27 -0
  58. package/dist/passes/route-handler.js +87 -0
  59. package/dist/passes/route-location.d.ts +16 -0
  60. package/dist/passes/route-location.js +58 -0
  61. package/dist/passes/route-relation-resolve.d.ts +51 -0
  62. package/dist/passes/route-relation-resolve.js +172 -0
  63. package/dist/passes/route-relations-diagnostics.d.ts +15 -0
  64. package/dist/passes/route-relations-diagnostics.js +79 -0
  65. package/dist/passes/route-relations-follow.d.ts +16 -0
  66. package/dist/passes/route-relations-follow.js +116 -0
  67. package/dist/passes/route-relations-usage.d.ts +50 -0
  68. package/dist/passes/route-relations-usage.js +44 -0
  69. package/dist/passes/route-relations-walk.d.ts +25 -0
  70. package/dist/passes/route-relations-walk.js +199 -0
  71. package/dist/passes/route-relations.d.ts +17 -0
  72. package/dist/passes/route-relations.js +43 -0
  73. package/dist/passes/route-table.d.ts +21 -0
  74. package/dist/passes/route-table.js +104 -0
  75. package/dist/passes/route-tables.d.ts +10 -0
  76. package/dist/passes/route-tables.js +42 -0
  77. package/dist/passes/routes.d.ts +18 -0
  78. package/dist/passes/routes.js +152 -0
  79. package/dist/passes/schema-column-types.d.ts +9 -0
  80. package/dist/passes/schema-column-types.js +55 -0
  81. package/dist/passes/schema-columns.d.ts +35 -0
  82. package/dist/passes/schema-columns.js +197 -0
  83. package/dist/passes/schema-dsl.d.ts +24 -0
  84. package/dist/passes/schema-dsl.js +117 -0
  85. package/dist/passes/schema-file-links-ddl.d.ts +18 -0
  86. package/dist/passes/schema-file-links-ddl.js +106 -0
  87. package/dist/passes/schema-file-links.d.ts +19 -0
  88. package/dist/passes/schema-file-links.js +144 -0
  89. package/dist/passes/schema-foreign-keys.d.ts +21 -0
  90. package/dist/passes/schema-foreign-keys.js +80 -0
  91. package/dist/passes/schema-policies.d.ts +15 -0
  92. package/dist/passes/schema-policies.js +201 -0
  93. package/dist/passes/schema-re-exports.d.ts +5 -0
  94. package/dist/passes/schema-re-exports.js +77 -0
  95. package/dist/passes/schema-relations.d.ts +8 -0
  96. package/dist/passes/schema-relations.js +182 -0
  97. package/dist/passes/schema-scope.d.ts +7 -0
  98. package/dist/passes/schema-scope.js +20 -0
  99. package/dist/passes/schema-tables.d.ts +13 -0
  100. package/dist/passes/schema-tables.js +149 -0
  101. package/dist/passes/schema.d.ts +9 -0
  102. package/dist/passes/schema.js +55 -0
  103. package/dist/passes/screen-components.d.ts +22 -0
  104. package/dist/passes/screen-components.js +143 -0
  105. package/dist/passes/screen-intrinsics.d.ts +18 -0
  106. package/dist/passes/screen-intrinsics.js +21 -0
  107. package/dist/passes/screen-preview.d.ts +29 -0
  108. package/dist/passes/screen-preview.js +174 -0
  109. package/dist/passes/screens.d.ts +12 -0
  110. package/dist/passes/screens.js +278 -0
  111. package/dist/passes/static-read.d.ts +47 -0
  112. package/dist/passes/static-read.js +151 -0
  113. package/dist/passes/views.d.ts +20 -0
  114. package/dist/passes/views.js +101 -0
  115. package/dist/passes/workflow-tables.d.ts +18 -0
  116. package/dist/passes/workflow-tables.js +71 -0
  117. package/dist/passes/workflows.d.ts +8 -0
  118. package/dist/passes/workflows.js +160 -0
  119. package/dist/path-resolver.d.ts +20 -0
  120. package/dist/path-resolver.js +134 -0
  121. package/dist/revision.d.ts +2 -0
  122. package/dist/revision.js +23 -0
  123. package/dist/source.d.ts +4 -0
  124. package/dist/source.js +13 -0
  125. package/dist/static-eval.d.ts +36 -0
  126. package/dist/static-eval.js +215 -0
  127. package/dist/types-graph.d.ts +144 -0
  128. package/dist/types-graph.js +4 -0
  129. package/dist/types-jobs.d.ts +28 -0
  130. package/dist/types-jobs.js +14 -0
  131. package/dist/types-relations.d.ts +49 -0
  132. package/dist/types-relations.js +11 -0
  133. package/dist/types-workflows.d.ts +36 -0
  134. package/dist/types-workflows.js +3 -0
  135. package/dist/types.d.ts +226 -0
  136. package/dist/types.js +1 -0
  137. package/package.json +49 -4
  138. package/src/analyze.ts +119 -0
  139. package/src/ast/node.ts +102 -0
  140. package/src/ast/walk.ts +37 -0
  141. package/src/diagnostics.ts +22 -0
  142. package/src/fs.ts +59 -0
  143. package/src/graph-edges.ts +167 -0
  144. package/src/graph-ids.ts +31 -0
  145. package/src/graph.ts +194 -0
  146. package/src/index.ts +26 -0
  147. package/src/json.ts +34 -0
  148. package/src/module-graph.ts +117 -0
  149. package/src/module-info.ts +162 -0
  150. package/src/node.ts +54 -0
  151. package/src/parser.ts +19 -0
  152. package/src/passes/clients.ts +233 -0
  153. package/src/passes/config.ts +278 -0
  154. package/src/passes/file-facts.ts +142 -0
  155. package/src/passes/i18n.ts +66 -0
  156. package/src/passes/job-relations-diagnostics.ts +60 -0
  157. package/src/passes/job-relations.ts +76 -0
  158. package/src/passes/jobs.ts +181 -0
  159. package/src/passes/jsx-text.ts +126 -0
  160. package/src/passes/jsx.ts +142 -0
  161. package/src/passes/path-expression.ts +297 -0
  162. package/src/passes/route-config.ts +188 -0
  163. package/src/passes/route-description.ts +33 -0
  164. package/src/passes/route-elevation.ts +161 -0
  165. package/src/passes/route-handler.ts +105 -0
  166. package/src/passes/route-location.ts +62 -0
  167. package/src/passes/route-relation-resolve.ts +228 -0
  168. package/src/passes/route-relations-diagnostics.ts +115 -0
  169. package/src/passes/route-relations-follow.ts +146 -0
  170. package/src/passes/route-relations-usage.ts +98 -0
  171. package/src/passes/route-relations-walk.ts +255 -0
  172. package/src/passes/route-relations.ts +62 -0
  173. package/src/passes/route-table.ts +123 -0
  174. package/src/passes/route-tables.ts +50 -0
  175. package/src/passes/routes.ts +188 -0
  176. package/src/passes/schema-column-types.ts +62 -0
  177. package/src/passes/schema-columns.ts +287 -0
  178. package/src/passes/schema-dsl.ts +136 -0
  179. package/src/passes/schema-file-links-ddl.ts +125 -0
  180. package/src/passes/schema-file-links.ts +213 -0
  181. package/src/passes/schema-foreign-keys.ts +121 -0
  182. package/src/passes/schema-policies.ts +270 -0
  183. package/src/passes/schema-re-exports.ts +95 -0
  184. package/src/passes/schema-relations.ts +239 -0
  185. package/src/passes/schema-scope.ts +29 -0
  186. package/src/passes/schema-tables.ts +216 -0
  187. package/src/passes/schema.ts +67 -0
  188. package/src/passes/screen-components.ts +162 -0
  189. package/src/passes/screen-intrinsics.ts +29 -0
  190. package/src/passes/screen-preview.ts +295 -0
  191. package/src/passes/screens.ts +291 -0
  192. package/src/passes/static-read.ts +196 -0
  193. package/src/passes/views.ts +125 -0
  194. package/src/passes/workflow-tables.ts +87 -0
  195. package/src/passes/workflows.ts +213 -0
  196. package/src/path-resolver.ts +146 -0
  197. package/src/revision.ts +25 -0
  198. package/src/source.ts +15 -0
  199. package/src/static-eval.ts +242 -0
  200. package/src/types-graph.ts +176 -0
  201. package/src/types-jobs.ts +40 -0
  202. package/src/types-relations.ts +64 -0
  203. package/src/types-workflows.ts +42 -0
  204. package/src/types.ts +299 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Shared state of the route-relations walk (D-157): the mutable relation-usage accumulators
3
+ * and the per-frame scope. Kept in one module so the walk and the follow helpers agree on
4
+ * their shape without importing each other.
5
+ */
6
+
7
+ import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
8
+ import type {
9
+ RouteElevationUsage,
10
+ RouteRelationUse,
11
+ RouteRelationsFact,
12
+ } from "../types-relations.ts";
13
+ import type { ElevationKind, SourceRef } from "../types.ts";
14
+ import type { DbClient, RelationIndex } from "./route-relation-resolve.ts";
15
+
16
+ export interface MutableElevation {
17
+ readonly kind: ElevationKind;
18
+ readonly source: SourceRef;
19
+ readonly tables: readonly string[];
20
+ readonly tablesUnknown: boolean;
21
+ readonly used: Map<string, SourceRef>;
22
+ readonly unresolved: SourceRef[];
23
+ opaque: boolean;
24
+ }
25
+
26
+ export interface MutableUsage {
27
+ readonly used: Map<string, SourceRef>;
28
+ readonly unresolved: SourceRef[];
29
+ readonly rawSql: SourceRef[];
30
+ opaque: boolean;
31
+ readonly elevations: MutableElevation[];
32
+ }
33
+
34
+ /** One handler or project-local helper call frame. */
35
+ export interface RelationScope {
36
+ readonly graph: ModuleGraph;
37
+ readonly index: RelationIndex;
38
+ readonly file: ProjectFile;
39
+ readonly env: Map<string, DbClient>;
40
+ readonly aliases: Map<string, string>;
41
+ readonly elevationFns: Map<string, ElevationKind>;
42
+ readonly contextName: string | null;
43
+ /**
44
+ * Whether the scanned body can elevate (`ctx.sudo`/`ctx.system`). Jobs already run
45
+ * system-scoped, so their pass has no elevation dimension (D-157).
46
+ */
47
+ readonly elevations: boolean;
48
+ /** Elevation bucket of the current callback; null is the route's own ctx.db. */
49
+ readonly elevation: number | null;
50
+ readonly depth: number;
51
+ readonly stack: ReadonlySet<string>;
52
+ readonly usage: MutableUsage;
53
+ }
54
+
55
+ export function createUsage(): MutableUsage {
56
+ return { used: new Map(), unresolved: [], rawSql: [], opaque: false, elevations: [] };
57
+ }
58
+
59
+ function bucket(scope: RelationScope): MutableElevation | MutableUsage {
60
+ const index = scope.elevation;
61
+ return index === null ? scope.usage : (scope.usage.elevations[index] ?? scope.usage);
62
+ }
63
+
64
+ export function addUse(scope: RelationScope, name: string, source: SourceRef): void {
65
+ const target = bucket(scope);
66
+ if (!target.used.has(name)) target.used.set(name, source);
67
+ }
68
+
69
+ export function addUnresolved(scope: RelationScope, source: SourceRef): void {
70
+ bucket(scope).unresolved.push(source);
71
+ }
72
+
73
+ export function markOpaque(scope: RelationScope): void {
74
+ bucket(scope).opaque = true;
75
+ }
76
+
77
+ function freezeElevation(elevation: MutableElevation): RouteElevationUsage {
78
+ const used: RouteRelationUse[] = [...elevation.used].map(([name, source]) => ({ name, source }));
79
+ return {
80
+ kind: elevation.kind,
81
+ source: elevation.source,
82
+ tables: [...elevation.tables],
83
+ used,
84
+ unresolved: [...elevation.unresolved],
85
+ tablesUnknown: elevation.tablesUnknown,
86
+ opaque: elevation.opaque,
87
+ };
88
+ }
89
+
90
+ export function freezeUsage(usage: MutableUsage): RouteRelationsFact {
91
+ return {
92
+ used: [...usage.used].map(([name, source]) => ({ name, source })),
93
+ unresolved: [...usage.unresolved],
94
+ rawSql: [...usage.rawSql],
95
+ opaque: usage.opaque,
96
+ elevations: usage.elevations.map(freezeElevation),
97
+ };
98
+ }
@@ -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
+ }