@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,297 @@
1
+ /**
2
+ * Client path expressions -> static prefixes and route matches (§3.4 of the AST DSL
3
+ * contract).
4
+ *
5
+ * A path is read as a static prefix plus a dynamic tail: literal strings and const strings
6
+ * are exact, while concatenations and templates keep the leading static segments. One hop of
7
+ * a path-builder function is inlined (the builder's return expression); anything deeper is
8
+ * dynamic, not guessed.
9
+ */
10
+
11
+ import {
12
+ asNode,
13
+ identifierName,
14
+ isAstNode,
15
+ nodeArray,
16
+ unwrapExpression,
17
+ type AstNode,
18
+ } from "../ast/node.ts";
19
+ import { defaultExportDeclaration, localInitializer } from "../module-info.ts";
20
+ import { evaluateStatic, type StaticContext } from "../static-eval.ts";
21
+ import type { HttpMethod, PathKind, RouteFact, UnknownFact } from "../types.ts";
22
+ import { routeFactId } from "./route-location.ts";
23
+ import { findDeclaration, refOf } from "./static-read.ts";
24
+
25
+ const MAX_RESOLVE_DEPTH = 8;
26
+ export interface ExtractedPath {
27
+ readonly kind: PathKind;
28
+ /** Source text of the argument, for the inspector. */
29
+ readonly expression: string;
30
+ /** Leading static text; null when the path has no readable static part. */
31
+ readonly staticPrefix: string | null;
32
+ /** Fully static path, when the whole expression is known. */
33
+ readonly path: string | null;
34
+ readonly unknown: readonly UnknownFact[];
35
+ }
36
+ interface PartialPrefix {
37
+ readonly prefix: string;
38
+ readonly complete: boolean;
39
+ }
40
+
41
+ function sourceText(source: string, node: AstNode | undefined): string {
42
+ if (node === undefined || typeof node.start !== "number" || typeof node.end !== "number") {
43
+ return "";
44
+ }
45
+ return source.slice(node.start, node.end).trim();
46
+ }
47
+
48
+ function cookedString(quasi: AstNode | undefined): string {
49
+ // TemplateElement.value is a plain { raw, cooked } record, not an AST node (asNode rejects it).
50
+ const value = quasi?.value;
51
+ if (typeof value !== "object" || value === null) return "";
52
+ const cooked = (value as Record<string, unknown>).cooked;
53
+ return typeof cooked === "string" ? cooked : "";
54
+ }
55
+
56
+ function evaluatePrimitive(node: AstNode, context: StaticContext): string | null {
57
+ const evaluated = evaluateStatic(node, context);
58
+ const value = evaluated.ok ? evaluated.value : undefined;
59
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
60
+ ? String(value)
61
+ : null;
62
+ }
63
+
64
+ /** Leading static text of a literal, template or `+` chain, plus whether it is whole. */
65
+ function partialStaticPrefix(
66
+ node: AstNode | undefined,
67
+ context: StaticContext,
68
+ depth = 0,
69
+ ): PartialPrefix | null {
70
+ if (node === undefined || depth > MAX_RESOLVE_DEPTH) return null;
71
+ const unwrapped = unwrapExpression(node);
72
+ if (unwrapped === undefined) return null;
73
+ if (unwrapped.type === "Literal") {
74
+ return typeof unwrapped.value === "string" ? { prefix: unwrapped.value, complete: true } : null;
75
+ }
76
+ if (unwrapped.type === "TemplateLiteral") {
77
+ const quasis = nodeArray(unwrapped, "quasis");
78
+ const expressions = nodeArray(unwrapped, "expressions");
79
+ let prefix = "";
80
+ for (const [index, quasi] of quasis.entries()) {
81
+ prefix += cookedString(quasi);
82
+ const expression = expressions[index];
83
+ if (expression === undefined) continue;
84
+ const value = evaluatePrimitive(expression, context);
85
+ if (value === null) return { prefix, complete: false };
86
+ prefix += value;
87
+ }
88
+ return { prefix, complete: true };
89
+ }
90
+ if (unwrapped.type === "BinaryExpression" && unwrapped.operator === "+") {
91
+ const left = partialStaticPrefix(asNode(unwrapped.left), context, depth + 1);
92
+ if (left === null) return null;
93
+ if (!left.complete) return left;
94
+ const right = partialStaticPrefix(asNode(unwrapped.right), context, depth + 1);
95
+ return right === null
96
+ ? { prefix: left.prefix, complete: false }
97
+ : { prefix: left.prefix + right.prefix, complete: right.complete };
98
+ }
99
+ const value = evaluatePrimitive(unwrapped, context);
100
+ return value === null ? null : { prefix: value, complete: true };
101
+ }
102
+
103
+ function firstReturn(node: AstNode): AstNode | undefined {
104
+ if (node.type === "ReturnStatement") return asNode(node.argument);
105
+ for (const key of Object.keys(node)) {
106
+ if (key === "type" || key === "start" || key === "end") continue;
107
+ const value = node[key];
108
+ const children = (Array.isArray(value) ? value : [value]).filter(isAstNode);
109
+ for (const child of children) {
110
+ // A nested function owns its return statements; only this function's count.
111
+ if (child.type.endsWith("FunctionExpression") || child.type === "FunctionDeclaration") {
112
+ continue;
113
+ }
114
+ const found = firstReturn(child);
115
+ if (found !== undefined) return found;
116
+ }
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function functionReturnExpression(fn: AstNode): AstNode | undefined {
122
+ const body = asNode(fn.body);
123
+ if (body === undefined) return undefined;
124
+ return body.type === "BlockStatement" ? firstReturn(body) : body;
125
+ }
126
+
127
+ /** Inline one hop of an imported path-builder function (its return expression). */
128
+ function resolvePathBuilder(call: AstNode, context: StaticContext): PartialPrefix | null {
129
+ const callee = unwrapExpression(call.callee);
130
+ const name = identifierName(callee);
131
+ const file = context.graph.file(context.file);
132
+ if (name === undefined || file === undefined) return null;
133
+ const imported = file.module.imports.find((entry) => entry.local === name);
134
+ if (imported === undefined) return null;
135
+ const target = context.graph.resolve(context.file, imported.specifier);
136
+ if (target === null || target.startsWith("node_modules/")) return null;
137
+ const binding = context.graph.resolveExport(target, imported.imported ?? "default");
138
+ if (binding === null) return null;
139
+ const module = context.graph.file(binding.file);
140
+ if (module === undefined) return null;
141
+ const initializer =
142
+ binding.name === "default"
143
+ ? defaultExportDeclaration(module.program)
144
+ : (localInitializer(module.program, binding.name) ??
145
+ findDeclaration(module.program, binding.name));
146
+ const fn = unwrapExpression(initializer);
147
+ if (fn === undefined || !/Function/.test(fn.type)) return null;
148
+ const returned = functionReturnExpression(fn);
149
+ if (returned === undefined) return null;
150
+ return partialStaticPrefix(returned, { graph: context.graph, file: module.path });
151
+ }
152
+
153
+ /** Extract the path of one client call; failures stay in the unknown channel. */
154
+ export function extractPathExpression(
155
+ argument: AstNode | undefined,
156
+ context: StaticContext,
157
+ call: AstNode,
158
+ code: string,
159
+ ): ExtractedPath {
160
+ const file = context.graph.file(context.file);
161
+ const source = file?.source ?? "";
162
+ const callRef = refOf(context, call);
163
+ const argumentNode = unwrapExpression(argument);
164
+ const expression = sourceText(source, argumentNode) || sourceText(source, call);
165
+ if (argumentNode === undefined) {
166
+ return {
167
+ kind: "dynamic",
168
+ expression,
169
+ staticPrefix: null,
170
+ path: null,
171
+ unknown: [{ code, reason: "the call has no path argument", source: callRef }],
172
+ };
173
+ }
174
+
175
+ const unknown: UnknownFact[] = [];
176
+ if (argumentNode.type === "CallExpression") {
177
+ const builder = resolvePathBuilder(argumentNode, context);
178
+ if (builder !== null) {
179
+ return {
180
+ kind: "builder",
181
+ expression,
182
+ staticPrefix: builder.prefix.length === 0 ? null : builder.prefix,
183
+ path: builder.complete ? builder.prefix : null,
184
+ unknown,
185
+ };
186
+ }
187
+ unknown.push({
188
+ code,
189
+ reason: "the path builder could not be resolved statically",
190
+ source: callRef,
191
+ });
192
+ return { kind: "unknown", expression, staticPrefix: null, path: null, unknown };
193
+ }
194
+
195
+ if (argumentNode.type === "TemplateLiteral" || argumentNode.type === "BinaryExpression") {
196
+ const partial = partialStaticPrefix(argumentNode, context);
197
+ if (partial !== null) {
198
+ return {
199
+ kind: argumentNode.type === "TemplateLiteral" ? "template" : "concat",
200
+ expression,
201
+ staticPrefix: partial.prefix.length === 0 ? null : partial.prefix,
202
+ path: partial.complete ? partial.prefix : null,
203
+ unknown,
204
+ };
205
+ }
206
+ }
207
+
208
+ const evaluated = evaluateStatic(argumentNode, context);
209
+ if (evaluated.ok && typeof evaluated.value === "string") {
210
+ return {
211
+ kind: "literal",
212
+ expression,
213
+ staticPrefix: evaluated.value.length === 0 ? null : evaluated.value,
214
+ path: evaluated.value,
215
+ unknown,
216
+ };
217
+ }
218
+ unknown.push({
219
+ code,
220
+ reason: evaluated.ok ? "the path is not a string" : evaluated.reason,
221
+ source: callRef,
222
+ });
223
+ return { kind: "dynamic", expression, staticPrefix: null, path: null, unknown };
224
+ }
225
+
226
+ function pathSegments(path: string): string[] {
227
+ const base = path.split("?")[0] ?? "";
228
+ return (base.split("#")[0] ?? "").split("/").filter(Boolean);
229
+ }
230
+
231
+ function isDynamicSegment(segment: string): boolean {
232
+ return segment.startsWith("{") && segment.endsWith("}");
233
+ }
234
+
235
+ function templateMatches(template: readonly string[], segments: readonly string[]): boolean {
236
+ let index = 0;
237
+ for (; index < template.length; index += 1) {
238
+ const templateSegment = template[index] ?? "";
239
+ if (templateSegment.startsWith("{*")) return segments.length > index;
240
+ if (isDynamicSegment(templateSegment)) {
241
+ if (index >= segments.length) return false;
242
+ continue;
243
+ }
244
+ if (segments[index] !== templateSegment) return false;
245
+ }
246
+ return segments.length === index;
247
+ }
248
+
249
+ function prefixMatches(
250
+ template: readonly string[],
251
+ prefixSegments: readonly string[],
252
+ trailingSlash: boolean,
253
+ ): boolean {
254
+ for (const [index, prefixSegment] of prefixSegments.entries()) {
255
+ const templateSegment = template[index];
256
+ if (templateSegment === undefined) return false;
257
+ if (templateSegment.startsWith("{*")) return false;
258
+ if (isDynamicSegment(templateSegment)) continue;
259
+ if (templateSegment !== prefixSegment) return false;
260
+ }
261
+ const rest = template.slice(prefixSegments.length);
262
+ // A trailing slash means at least one more (dynamic) segment at the call site.
263
+ if (trailingSlash && rest.length === 0) return false;
264
+ return rest.every((segment) => isDynamicSegment(segment));
265
+ }
266
+
267
+ function routeMatches(
268
+ routePath: string,
269
+ path: string | null,
270
+ staticPrefix: string | null,
271
+ ): boolean {
272
+ const template = pathSegments(routePath);
273
+ if (path !== null) return templateMatches(template, pathSegments(path));
274
+ if (staticPrefix === null) return false;
275
+ return prefixMatches(template, pathSegments(staticPrefix), staticPrefix.endsWith("/"));
276
+ }
277
+
278
+ /** Route ids whose template can produce the call path for the given method. */
279
+ export function matchRouteIds(
280
+ routes: readonly RouteFact[],
281
+ path: string | null,
282
+ staticPrefix: string | null,
283
+ method: HttpMethod,
284
+ ): string[] {
285
+ if (path === null && staticPrefix === null) return [];
286
+ const ids: string[] = [];
287
+ const seen = new Set<string>();
288
+ for (const route of routes) {
289
+ if (route.method !== "ALL" && route.method !== method) continue;
290
+ if (!routeMatches(route.path, path, staticPrefix)) continue;
291
+ const id = routeFactId(route);
292
+ if (seen.has(id)) continue;
293
+ seen.add(id);
294
+ ids.push(id);
295
+ }
296
+ return ids;
297
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The wrapper config subset: permission/crossSite/dbTables/dbColumns/description/input.
3
+ *
4
+ * Split from routes.ts so both modules stay under the 300-line limit; every failure is an
5
+ * explicit unknown (never an empty value) and a diagnostic, as in the rest of the analyzer.
6
+ */
7
+
8
+ import { callArguments, type AstNode } from "../ast/node.ts";
9
+ import { error, warning } from "../diagnostics.ts";
10
+ import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
11
+ import type { StaticContext } from "../static-eval.ts";
12
+ import type { ConfigFacts, Diagnostic, RouteInputFact, SourceRef, UnknownFact } from "../types.ts";
13
+ import { descriptionDiagnostics } from "./route-description.ts";
14
+ import { readStaticObject, refOf, type StaticField } from "./static-read.ts";
15
+
16
+ const MISSING_PERMISSION = "permission is missing";
17
+ const MISSING_TABLES = "dbTables is missing; declare an array ([] means no DB)";
18
+
19
+ export interface RouteConfigFacts {
20
+ readonly permission: readonly string[] | null;
21
+ /** True when the wrapper opts the route into cross-origin calls; null when unreadable. */
22
+ readonly crossSite: boolean | null;
23
+ /** rawSql: true allows `db.execute` (D-157); false when absent; null when unreadable. */
24
+ readonly rawSql: boolean | null;
25
+ readonly dbTables: readonly string[] | null;
26
+ readonly dbColumns: readonly string[] | null;
27
+ readonly description: string | null;
28
+ readonly input: RouteInputFact;
29
+ readonly unknown: readonly UnknownFact[];
30
+ readonly diagnostics: readonly Diagnostic[];
31
+ }
32
+
33
+ function stringArrayValue(value: unknown): string[] | null {
34
+ if (!Array.isArray(value)) return null;
35
+ return value.every((item): item is string => typeof item === "string") ? [...value] : null;
36
+ }
37
+
38
+ function permissionValue(value: unknown): string[] | null {
39
+ return typeof value === "string" ? [value] : stringArrayValue(value);
40
+ }
41
+
42
+ function addUnknown(list: UnknownFact[], code: string, reason: string, source: SourceRef): void {
43
+ list.push({ code, reason, source });
44
+ }
45
+
46
+ function unreadable(field: string, entry: StaticField): string {
47
+ return field + " cannot be read statically: " + (entry.reason ?? "unsupported expression");
48
+ }
49
+
50
+ /** Read the wrapper config from the static subset; every failure is explicit. */
51
+ export function routeConfigFacts(
52
+ graph: ModuleGraph,
53
+ file: ProjectFile,
54
+ call: AstNode,
55
+ config: ConfigFacts,
56
+ ): RouteConfigFacts {
57
+ const context: StaticContext = { graph, file: file.path };
58
+ const ref = refOf(context, call);
59
+ // Input schemas (body/query/params) are read for presence only: a Zod expression there is
60
+ // expected, so only the security/display fields below report a static-read failure.
61
+ const object = readStaticObject(callArguments(call)[0], context, {
62
+ code: "route-config-static",
63
+ report: false,
64
+ });
65
+ const unknown: UnknownFact[] = [...object.unknown];
66
+ const field = (name: string): StaticField | undefined => object.fields.get(name);
67
+
68
+ const permissionField = field("permission");
69
+ let permission: string[] | null = null;
70
+ if (permissionField === undefined) {
71
+ addUnknown(unknown, "route-config-static", MISSING_PERMISSION, ref);
72
+ } else if (!permissionField.ok) {
73
+ addUnknown(unknown, "route-config-static", unreadable("permission", permissionField), ref);
74
+ } else {
75
+ permission = permissionValue(permissionField.value);
76
+ if (permission === null) {
77
+ addUnknown(unknown, "route-config-static", "permission must be a string or string[]", ref);
78
+ }
79
+ }
80
+
81
+ const crossSiteField = field("crossSite");
82
+ let crossSite: boolean | null = false;
83
+ if (crossSiteField !== undefined && !crossSiteField.ok) {
84
+ crossSite = null;
85
+ addUnknown(unknown, "route-config-static", unreadable("crossSite", crossSiteField), ref);
86
+ } else if (crossSiteField !== undefined) {
87
+ if (typeof crossSiteField.value === "boolean") crossSite = crossSiteField.value;
88
+ else {
89
+ crossSite = null;
90
+ addUnknown(unknown, "route-config-static", "crossSite must be a boolean", ref);
91
+ }
92
+ }
93
+
94
+ const rawSqlField = field("rawSql");
95
+ let rawSql: boolean | null = false;
96
+ if (rawSqlField !== undefined && !rawSqlField.ok) {
97
+ rawSql = null;
98
+ addUnknown(unknown, "route-config-static", unreadable("rawSql", rawSqlField), ref);
99
+ } else if (rawSqlField !== undefined) {
100
+ if (typeof rawSqlField.value === "boolean") rawSql = rawSqlField.value;
101
+ else {
102
+ // An unreadable derogation is never assumed: db.execute then fails closed (D-157).
103
+ rawSql = null;
104
+ addUnknown(unknown, "route-config-static", "rawSql must be a boolean", ref);
105
+ }
106
+ }
107
+
108
+ const tablesField = field("dbTables");
109
+ let dbTables: string[] | null = null;
110
+ if (tablesField === undefined) {
111
+ addUnknown(unknown, "db-tables-present", MISSING_TABLES, ref);
112
+ } else if (!tablesField.ok) {
113
+ addUnknown(unknown, "route-config-static", unreadable("dbTables", tablesField), ref);
114
+ } else {
115
+ dbTables = stringArrayValue(tablesField.value);
116
+ if (dbTables === null) {
117
+ addUnknown(unknown, "route-config-static", "dbTables must be a string[]", ref);
118
+ }
119
+ }
120
+
121
+ const columnsField = field("dbColumns");
122
+ let dbColumns: string[] | null = columnsField === undefined ? [] : null;
123
+ if (columnsField !== undefined && !columnsField.ok) {
124
+ dbColumns = null;
125
+ addUnknown(unknown, "route-config-static", unreadable("dbColumns", columnsField), ref);
126
+ } else if (columnsField !== undefined) {
127
+ dbColumns = stringArrayValue(columnsField.value);
128
+ if (dbColumns === null) {
129
+ addUnknown(unknown, "route-config-static", "dbColumns must be a string[]", ref);
130
+ }
131
+ }
132
+
133
+ const descriptionField = field("description");
134
+ let description: string | null = null;
135
+ if (descriptionField !== undefined && !descriptionField.ok) {
136
+ addUnknown(unknown, "route-config-static", unreadable("description", descriptionField), ref);
137
+ } else if (descriptionField !== undefined) {
138
+ if (typeof descriptionField.value === "string") description = descriptionField.value;
139
+ else addUnknown(unknown, "route-config-static", "description must be a string", ref);
140
+ }
141
+
142
+ const input: RouteInputFact = {
143
+ body: field("body") !== undefined,
144
+ form: field("form") !== undefined,
145
+ query: field("query") !== undefined,
146
+ params: field("params") !== undefined,
147
+ };
148
+
149
+ const diagnostics: Diagnostic[] = [
150
+ ...unknown.map((item) => error(item.code, item.reason, item.source)),
151
+ ...descriptionDiagnostics(description, ref),
152
+ ];
153
+ if (permission !== null) {
154
+ for (const name of permission) {
155
+ if (!config.permissions.includes(name)) {
156
+ diagnostics.push(
157
+ error(
158
+ "permission-declared",
159
+ 'permission "' + name + '" is not declared in config.auth.permissions',
160
+ ref,
161
+ ),
162
+ );
163
+ }
164
+ }
165
+ }
166
+ // v3a: crossSite + a non-empty permission is legal now (the route is reached with a Bearer
167
+ // token); the warning only reminds the author that a session cookie never crosses origins.
168
+ if (crossSite === true && permission !== null && permission.length > 0) {
169
+ diagnostics.push(
170
+ warning(
171
+ "route-cross-site-authenticated",
172
+ "crossSite route requires a Bearer token: session cookies never cross origins",
173
+ ref,
174
+ ),
175
+ );
176
+ }
177
+ return {
178
+ permission,
179
+ crossSite,
180
+ rawSql,
181
+ dbTables,
182
+ dbColumns,
183
+ description,
184
+ input,
185
+ unknown,
186
+ diagnostics,
187
+ };
188
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Route description rules (D-129).
3
+ *
4
+ * The description is source documentation: one English sentence stored in the
5
+ * defineAuthorizedHandler config, shown under the route node and in the OpenAPI document.
6
+ * Two lines is its useful maximum, so the analyzer reports a warning when it runs long.
7
+ * The patch validator imports the same limit, so the AI and the user see one number.
8
+ */
9
+
10
+ import { warning } from "../diagnostics.ts";
11
+ import type { Diagnostic, SourceRef } from "../types.ts";
12
+
13
+ /** About two lines in the route definition and in the graph card. */
14
+ export const ROUTE_DESCRIPTION_MAX_CHARS = 120;
15
+
16
+ /** A warning when the description exceed the two-line budget; nothing to report otherwise. */
17
+ export function descriptionDiagnostics(
18
+ description: string | null,
19
+ source: SourceRef | null,
20
+ ): readonly Diagnostic[] {
21
+ if (description === null || description.length <= ROUTE_DESCRIPTION_MAX_CHARS) return [];
22
+ return [
23
+ warning(
24
+ "route-description-length",
25
+ "description is " +
26
+ description.length +
27
+ " characters; keep it to " +
28
+ ROUTE_DESCRIPTION_MAX_CHARS +
29
+ " (about two lines)",
30
+ source,
31
+ ),
32
+ ];
33
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Audited elevation facts from a route handler (D-099): `ctx.sudo` and `ctx.system`.
3
+ *
4
+ * Only the handler's own context can elevate: a destructured `{ sudo }`/`{ system }` binding
5
+ * or a member call on the context parameter. Any other identifier named `sudo` is ignored, so
6
+ * the graph cannot be colored by a local helper with the same name. The tables and operation
7
+ * are read through the bounded static subset; when that fails the fact is explicit (unknown
8
+ * plus an empty table list), never guessed.
9
+ */
10
+
11
+ import {
12
+ callArguments,
13
+ identifierName,
14
+ memberName,
15
+ nodeArray,
16
+ objectEntries,
17
+ propertyName,
18
+ propertyValue,
19
+ unwrapExpression,
20
+ type AstNode,
21
+ } from "../ast/node.ts";
22
+ import { walk } from "../ast/walk.ts";
23
+ import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
24
+ import type { ElevationFact, ElevationKind, SourceRef, UnknownFact } from "../types.ts";
25
+ import { readStaticObject } from "./static-read.ts";
26
+
27
+ export interface ElevationScan {
28
+ readonly facts: readonly ElevationFact[];
29
+ readonly unknown: readonly UnknownFact[];
30
+ }
31
+
32
+ function refOf(file: ProjectFile, node: AstNode): SourceRef {
33
+ const offset = typeof node.start === "number" ? node.start : 0;
34
+ let line = 1;
35
+ for (let index = 0; index < offset && index < file.source.length; index += 1) {
36
+ if (file.source.charCodeAt(index) === 10) line += 1;
37
+ }
38
+ return { file: file.path, line };
39
+ }
40
+
41
+ function elevationKind(name: string | undefined): ElevationKind | null {
42
+ if (name === "sudo" || name === "system") return name;
43
+ return null;
44
+ }
45
+
46
+ interface HandlerBindings {
47
+ readonly locals: ReadonlyMap<string, ElevationKind>;
48
+ readonly contextName: string | null;
49
+ }
50
+
51
+ /** Local identifier -> elevation kind for `{ sudo }` / `{ sudo: elevate }`. */
52
+ function handlerBindings(handler: AstNode): HandlerBindings {
53
+ const locals = new Map<string, ElevationKind>();
54
+ const context = nodeArray(handler, "params")[1];
55
+ if (context === undefined) return { locals, contextName: null };
56
+ if (context.type === "Identifier") {
57
+ return { locals, contextName: identifierName(context) ?? null };
58
+ }
59
+ if (context.type !== "ObjectPattern") return { locals, contextName: null };
60
+ for (const entry of objectEntries(context)) {
61
+ if (entry.type !== "Property") continue;
62
+ const kind = elevationKind(propertyName(entry));
63
+ if (kind === null) continue;
64
+ let value = propertyValue(entry);
65
+ if (value !== undefined && value.type === "AssignmentPattern") {
66
+ value = unwrapExpression(value.left);
67
+ }
68
+ const local = identifierName(unwrapExpression(value));
69
+ if (local !== undefined) locals.set(local, kind);
70
+ }
71
+ return { locals, contextName: null };
72
+ }
73
+
74
+ function stringArray(value: unknown): string[] | null {
75
+ if (!Array.isArray(value)) return null;
76
+ return value.every((entry): entry is string => typeof entry === "string") ? [...value] : null;
77
+ }
78
+
79
+ /** Scan one `defineAuthorizedHandler` call's handler for audited elevations. */
80
+ export function scanElevations(
81
+ graph: ModuleGraph,
82
+ file: ProjectFile,
83
+ wrapperCall: AstNode,
84
+ ): ElevationScan {
85
+ const handler = callArguments(wrapperCall)[1];
86
+ if (handler === undefined) return { facts: [], unknown: [] };
87
+ const handlerNode = unwrapExpression(handler);
88
+ if (
89
+ handlerNode === undefined ||
90
+ (handlerNode.type !== "ArrowFunctionExpression" && handlerNode.type !== "FunctionExpression")
91
+ ) {
92
+ return { facts: [], unknown: [] };
93
+ }
94
+ const bindings = handlerBindings(handlerNode);
95
+ const facts: ElevationFact[] = [];
96
+ const unknown: UnknownFact[] = [];
97
+ walk(handlerNode, (node) => {
98
+ if (node.type !== "CallExpression") return;
99
+ const callee = unwrapExpression(node.callee);
100
+ if (callee === undefined) return;
101
+ let kind: ElevationKind | null = null;
102
+ if (callee.type === "Identifier") {
103
+ kind = bindings.locals.get(identifierName(callee) ?? "") ?? null;
104
+ } else if (callee.type === "MemberExpression") {
105
+ const name = memberName(callee);
106
+ const object = unwrapExpression(callee.object);
107
+ if (
108
+ name !== undefined &&
109
+ object?.type === "Identifier" &&
110
+ identifierName(object) === bindings.contextName
111
+ ) {
112
+ kind = elevationKind(name);
113
+ }
114
+ }
115
+ if (kind === null) return;
116
+ const source = refOf(file, node);
117
+ const options = readStaticObject(
118
+ callArguments(node)[0],
119
+ { graph, file: file.path },
120
+ {
121
+ code: "route-elevation-static",
122
+ report: true,
123
+ },
124
+ );
125
+ const factUnknown: UnknownFact[] = [...options.unknown];
126
+ const tablesField = options.fields.get("tables");
127
+ let tables: string[] = [];
128
+ if (tablesField === undefined) {
129
+ factUnknown.push({
130
+ code: "route-elevation-static",
131
+ reason: "elevation tables are missing",
132
+ source,
133
+ });
134
+ } else if (!tablesField.ok) {
135
+ factUnknown.push({
136
+ code: "route-elevation-static",
137
+ reason: "elevation tables cannot be read statically",
138
+ source,
139
+ });
140
+ } else {
141
+ const read = stringArray(tablesField.value);
142
+ if (read === null) {
143
+ factUnknown.push({
144
+ code: "route-elevation-static",
145
+ reason: "elevation tables must be a string[]",
146
+ source,
147
+ });
148
+ } else {
149
+ tables = read;
150
+ }
151
+ }
152
+ const operationField = options.fields.get("operation");
153
+ const operation =
154
+ operationField !== undefined && operationField.ok && typeof operationField.value === "string"
155
+ ? operationField.value
156
+ : null;
157
+ unknown.push(...factUnknown);
158
+ facts.push({ kind, tables, operation, source, unknown: factUnknown });
159
+ });
160
+ return { facts, unknown };
161
+ }