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