@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,105 @@
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
+
13
+ import {
14
+ asNode,
15
+ callArguments,
16
+ memberName,
17
+ objectEntries,
18
+ propertyName,
19
+ unwrapExpression,
20
+ type AstNode,
21
+ } from "../ast/node.ts";
22
+ import { walk } from "../ast/walk.ts";
23
+ import { warning } from "../diagnostics.ts";
24
+ import type { ProjectFile } from "../module-graph.ts";
25
+ import { sourceRef } from "../source.ts";
26
+ import type { Diagnostic, SourceRef } from "../types.ts";
27
+
28
+ const RAW_DB_SPECIFIER = "@facilis.dev/db";
29
+ const RAW_DB_FACTORY = "createDb";
30
+
31
+ /** Warning text for raw/direct DB access in a route file. */
32
+ export const ROUTE_DIRECT_DB_MESSAGE =
33
+ "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).";
34
+
35
+ function nodeRef(file: ProjectFile, node: AstNode, fallback: SourceRef): SourceRef {
36
+ return typeof node.start === "number" ? sourceRef(file.path, file.source, node.start) : fallback;
37
+ }
38
+
39
+ /** True when the object chain of `node` contains a `.facilis` property. */
40
+ function touchesFacilisContext(node: AstNode): boolean {
41
+ let current: AstNode | undefined = node;
42
+ while (current !== undefined && current.type === "MemberExpression") {
43
+ if (memberName(current) === "facilis") return true;
44
+ current = asNode(unwrapExpression(current.object));
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /** `event.context.facilis.db` (or any `*.facilis.db` access): the raw policy-free client. */
50
+ function isDirectDbMember(node: AstNode): boolean {
51
+ if (node.type !== "MemberExpression" || memberName(node) !== "db") return false;
52
+ const object = asNode(unwrapExpression(node.object));
53
+ return object !== undefined && touchesFacilisContext(object);
54
+ }
55
+
56
+ /** True when the handler reads `name` off the request: member access or destructuring. */
57
+ function handlerReads(call: AstNode, name: string): boolean {
58
+ const handler = callArguments(call)[1];
59
+ if (handler === undefined) return false;
60
+ let found = false;
61
+ walk(handler, (node) => {
62
+ if (found) return;
63
+ if (node.type === "MemberExpression" && memberName(node) === name) {
64
+ found = true;
65
+ return;
66
+ }
67
+ if (
68
+ node.type === "ObjectPattern" &&
69
+ objectEntries(node).some((entry) => entry.type === "Property" && propertyName(entry) === name)
70
+ ) {
71
+ found = true;
72
+ }
73
+ });
74
+ return found;
75
+ }
76
+
77
+ /** True when the handler reads `.body` or destructures a `{ body }` pattern. */
78
+ export function handlerReadsBody(call: AstNode): boolean {
79
+ return handlerReads(call, "body");
80
+ }
81
+
82
+ /** True when the handler reads `.form` or destructures a `{ form }` pattern (D-053). */
83
+ export function handlerReadsForm(call: AstNode): boolean {
84
+ return handlerReads(call, "form");
85
+ }
86
+
87
+ /**
88
+ * Warnings for raw/direct DB access in a route file: `event.context.facilis.db` bypasses
89
+ * the policy-scoped client, and importing the `createDb` factory is the raw-client path.
90
+ * The source points at the offending node (or the route when a node has no position), so
91
+ * the IDE can jump to it.
92
+ */
93
+ export function directDbWarnings(file: ProjectFile, fallback: SourceRef): Diagnostic[] {
94
+ const refs: SourceRef[] = [];
95
+ walk(file.program, (node) => {
96
+ if (isDirectDbMember(node)) refs.push(nodeRef(file, node, fallback));
97
+ });
98
+ for (const imported of file.module.imports) {
99
+ if (imported.specifier === RAW_DB_SPECIFIER && imported.imported === RAW_DB_FACTORY) {
100
+ refs.push(imported.source);
101
+ }
102
+ }
103
+ refs.sort((left, right) => left.line - right.line);
104
+ return refs.map((source) => warning("route-direct-db", ROUTE_DIRECT_DB_MESSAGE, source));
105
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Route file name -> method/path (Nitro's file router, SPEC §4.2).
3
+ *
4
+ * Kept apart from the route pass so the filename rules are testable and the pass stays within
5
+ * the file-length limit. Two deliberate extensions over @facilis.dev/nitro's route-path: every
6
+ * HTTP method is recognized, and a method-less file serves ALL methods (Nitro's behavior).
7
+ */
8
+
9
+ import type { HttpMethod, RouteFact } from "../types.ts";
10
+
11
+ export interface RouteLocation {
12
+ readonly method: HttpMethod;
13
+ readonly path: string;
14
+ }
15
+
16
+ const METHOD_SUFFIX = /\.(get|post|put|patch|delete|head|options|trace|connect)$/;
17
+ const ENV_SUFFIX = /\.(dev|prod|prerender)$/;
18
+ const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?)$/;
19
+
20
+ function toOpenApiSegment(segment: string): string {
21
+ // Optional params `[[id]]` and catch-all `[...slug]` keep their special form; named
22
+ // params become OpenAPI templates, matching the spec Nitro generates.
23
+ const optional = /^\[\[(.+)\]\]$/.exec(segment);
24
+ if (optional?.[1] !== undefined) return "{" + optional[1] + "}";
25
+ const catchAll = /^\[\.\.\.(.+)\]$/.exec(segment);
26
+ if (catchAll?.[1] !== undefined) return "{*" + catchAll[1] + "}";
27
+ const named = /^\[(.+)\]$/.exec(segment);
28
+ if (named?.[1] !== undefined) return "{" + named[1] + "}";
29
+ return segment.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
30
+ }
31
+
32
+ function parseRouteSegment(segment: string): { name: string; method: HttpMethod } | undefined {
33
+ let name = segment;
34
+ const env = name.match(ENV_SUFFIX);
35
+ if (env !== null) name = name.slice(0, -env[0].length);
36
+ let method: HttpMethod = "ALL";
37
+ const methodMatch = name.match(METHOD_SUFFIX);
38
+ if (methodMatch?.[1] !== undefined) {
39
+ method = methodMatch[1].toUpperCase() as HttpMethod;
40
+ name = name.slice(0, -methodMatch[0].length);
41
+ }
42
+ return name.length === 0 ? undefined : { name, method };
43
+ }
44
+
45
+ /** Derive the route location from its file name relative to the routes directory. */
46
+ export function routeLocationFromFile(relativeRouteFile: string): RouteLocation | undefined {
47
+ const normalized = relativeRouteFile.replace(/\\/g, "/").replace(SOURCE_EXTENSION, "");
48
+ const segments = normalized.split("/").filter(Boolean);
49
+ const last = segments.length - 1;
50
+ if (last < 0) return undefined;
51
+ const parsed = parseRouteSegment(segments[last] ?? "");
52
+ if (parsed === undefined) return undefined;
53
+ if (parsed.name === "index") segments.pop();
54
+ else segments[last] = parsed.name;
55
+ const path = segments.filter((segment) => !/^\(.+\)$/.test(segment)).map(toOpenApiSegment);
56
+ return { method: parsed.method, path: path.length === 0 ? "/" : "/" + path.join("/") };
57
+ }
58
+
59
+ /** Stable route id shared by facts and the project graph. */
60
+ export function routeFactId(route: Pick<RouteFact, "method" | "path">): string {
61
+ return "route:" + route.method + " " + route.path;
62
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Name resolution for the route-relations pass (D-157). Split from route-relations.ts so both
3
+ * files stay under the 300-line limit.
4
+ *
5
+ * Two questions are answered here, without executing project code:
6
+ * - which relation (table or view) an expression denotes, following same-file consts, project
7
+ * imports, re-exports and namespace members;
8
+ * - which db client an expression denotes, so a use can be attributed to `ctx.db` or to one
9
+ * audited elevation callback.
10
+ *
11
+ * Anything outside the static subset is explicit: "opaque" means a package binding we cannot
12
+ * inspect (no error, the runtime guard is the authority), "unresolved" means dynamic project
13
+ * code (an error, D-157: route-table-unresolved).
14
+ */
15
+
16
+ import {
17
+ callArguments,
18
+ identifierName,
19
+ memberName,
20
+ unwrapExpression,
21
+ type AstNode,
22
+ } from "../ast/node.ts";
23
+ import { walk } from "../ast/walk.ts";
24
+ import { localInitializer } from "../module-info.ts";
25
+ import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
26
+ import type { TableFact, ViewFact } from "../types.ts";
27
+ import { findDeclaration } from "./static-read.ts";
28
+
29
+ export interface DbClient {
30
+ /** Elevation index in RouteRelationsFact.elevations; null is the route's ctx.db. */
31
+ readonly elevation: number | null;
32
+ }
33
+
34
+ export const ROUTE_CLIENT: DbClient = { elevation: null };
35
+ export type ClientEnv = ReadonlyMap<string, DbClient>;
36
+
37
+ export type RelationResolution =
38
+ | { readonly kind: "resolved"; readonly name: string }
39
+ | { readonly kind: "unresolved" }
40
+ | { readonly kind: "opaque" };
41
+
42
+ const UNRESOLVED: RelationResolution = { kind: "unresolved" };
43
+ const OPAQUE: RelationResolution = { kind: "opaque" };
44
+
45
+ export interface RelationIndex {
46
+ readonly names: ReadonlySet<string>;
47
+ resolve(
48
+ graph: ModuleGraph,
49
+ file: ProjectFile,
50
+ node: AstNode | undefined,
51
+ aliases: ReadonlyMap<string, string>,
52
+ depth?: number,
53
+ ): RelationResolution;
54
+ }
55
+
56
+ interface RelationLookup {
57
+ readonly byBinding: ReadonlyMap<string, string>;
58
+ readonly names: ReadonlySet<string>;
59
+ }
60
+
61
+ /** Table and view bindings, keyed by declaring file + binding (D-157). */
62
+ export function createRelationIndex(
63
+ tables: readonly TableFact[],
64
+ views: readonly ViewFact[],
65
+ ): RelationIndex {
66
+ const byBinding = new Map<string, string>();
67
+ const names = new Set<string>();
68
+ for (const table of tables) {
69
+ names.add(table.name);
70
+ if (table.binding !== null) byBinding.set(table.source.file + "#" + table.binding, table.name);
71
+ }
72
+ for (const view of views) {
73
+ names.add(view.name);
74
+ if (view.binding !== null) byBinding.set(view.source.file + "#" + view.binding, view.name);
75
+ }
76
+ const lookup: RelationLookup = { byBinding, names };
77
+ return {
78
+ names,
79
+ resolve: (graph, file, node, aliases, depth = 0) =>
80
+ resolveRelation(graph, file, node, aliases, lookup, depth),
81
+ };
82
+ }
83
+
84
+ const MAX_RELATION_DEPTH = 8;
85
+
86
+ function resolveRelation(
87
+ graph: ModuleGraph,
88
+ file: ProjectFile,
89
+ node: AstNode | undefined,
90
+ aliases: ReadonlyMap<string, string>,
91
+ lookup: RelationLookup,
92
+ depth: number,
93
+ ): RelationResolution {
94
+ const expression = unwrapExpression(node);
95
+ if (expression === undefined || depth > MAX_RELATION_DEPTH) return UNRESOLVED;
96
+ if (expression.type === "Identifier") {
97
+ const local = identifierName(expression);
98
+ if (local === undefined) return UNRESOLVED;
99
+ const alias = aliases.get(local);
100
+ if (alias !== undefined) return { kind: "resolved", name: alias };
101
+ const initializer = localInitializer(file.program, local);
102
+ if (initializer !== undefined) {
103
+ return resolveRelation(graph, file, initializer, aliases, lookup, depth + 1);
104
+ }
105
+ const binding = graph.resolveBinding(file.path, local);
106
+ if (binding === null) {
107
+ // Framework/package sources are often absent from the file map (the browser IDE's VFS
108
+ // carries project files only): an imported binding we cannot inspect is opaque, so the
109
+ // runtime guard stays the authority instead of a false "unresolved" error (D-157).
110
+ if (file.module.imports.some((entry) => entry.local === local)) return OPAQUE;
111
+ // A project file map may still omit a framework source: fall back to the SQL name.
112
+ return lookup.names.has(local) ? { kind: "resolved", name: local } : UNRESOLVED;
113
+ }
114
+ const direct = lookup.byBinding.get(binding.file + "#" + binding.name);
115
+ if (direct !== undefined) return { kind: "resolved", name: direct };
116
+ return binding.file.startsWith("node_modules/") ? OPAQUE : UNRESOLVED;
117
+ }
118
+ if (expression.type === "MemberExpression") {
119
+ const property = memberName(expression);
120
+ const object = unwrapExpression(expression.object);
121
+ if (property === undefined || object?.type !== "Identifier") return UNRESOLVED;
122
+ const namespace = identifierName(object);
123
+ if (namespace === undefined) return UNRESOLVED;
124
+ const imported = file.module.imports.find((entry) => entry.local === namespace);
125
+ if (imported === undefined || imported.kind !== "namespace") return UNRESOLVED;
126
+ const target = graph.resolve(file.path, imported.specifier);
127
+ if (target === null) return OPAQUE;
128
+ const name = lookup.byBinding.get(target + "#" + property);
129
+ return name === undefined ? UNRESOLVED : { kind: "resolved", name };
130
+ }
131
+ if (expression.type === "CallExpression") {
132
+ // alias(todos, "t") and similar wrappers: the relation is the first argument that resolves.
133
+ for (const argument of callArguments(expression)) {
134
+ const resolution = resolveRelation(graph, file, argument, aliases, lookup, depth + 1);
135
+ if (resolution.kind !== "unresolved") return resolution;
136
+ }
137
+ return UNRESOLVED;
138
+ }
139
+ return UNRESOLVED;
140
+ }
141
+
142
+ /** The db client an expression denotes directly (a local binding or `ctx.db`), if any. */
143
+ export function clientOf(
144
+ node: unknown,
145
+ env: ClientEnv,
146
+ contextName: string | null,
147
+ ): DbClient | null {
148
+ const expression = unwrapExpression(node);
149
+ if (expression === undefined) return null;
150
+ if (expression.type === "Identifier") {
151
+ const local = identifierName(expression);
152
+ return local === undefined ? null : (env.get(local) ?? null);
153
+ }
154
+ if (expression.type === "MemberExpression" && memberName(expression) === "db") {
155
+ const object = unwrapExpression(expression.object);
156
+ if (object?.type === "Identifier" && identifierName(object) === contextName)
157
+ return ROUTE_CLIENT;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /** The client at the root of a fluent chain (`db.select()`, `db.query.todos`). */
163
+ export function chainClient(
164
+ node: unknown,
165
+ env: ClientEnv,
166
+ contextName: string | null,
167
+ ): DbClient | null {
168
+ const expression = unwrapExpression(node);
169
+ if (expression === undefined) return null;
170
+ const direct = clientOf(expression, env, contextName);
171
+ if (direct !== null) return direct;
172
+ if (expression.type === "CallExpression") return chainClient(expression.callee, env, contextName);
173
+ if (expression.type === "MemberExpression")
174
+ return chainClient(expression.object, env, contextName);
175
+ return null;
176
+ }
177
+
178
+ /** True when a callee argument carries a db client anywhere (opaque callee detection). */
179
+ export function carriesClient(node: AstNode, env: ClientEnv, contextName: string | null): boolean {
180
+ let found = false;
181
+ walk(node, (candidate) => {
182
+ if (!found && clientOf(candidate, env, contextName) !== null) found = true;
183
+ });
184
+ return found;
185
+ }
186
+
187
+ export interface LocalFunction {
188
+ /** Stable identity for the call stack (cycle guard). */
189
+ readonly key: string;
190
+ readonly file: ProjectFile;
191
+ readonly node: AstNode;
192
+ }
193
+
194
+ const FUNCTION_TYPES = new Set([
195
+ "FunctionExpression",
196
+ "FunctionDeclaration",
197
+ "ArrowFunctionExpression",
198
+ ]);
199
+
200
+ function functionNode(node: AstNode | undefined): AstNode | undefined {
201
+ const candidate = unwrapExpression(node);
202
+ return candidate !== undefined && FUNCTION_TYPES.has(candidate.type) ? candidate : undefined;
203
+ }
204
+
205
+ /** A project-local function (never a package one: package calls stay opaque, D-157). */
206
+ export function resolveLocalFunction(
207
+ graph: ModuleGraph,
208
+ file: ProjectFile,
209
+ callee: unknown,
210
+ ): LocalFunction | null {
211
+ const expression = unwrapExpression(callee);
212
+ if (expression?.type !== "Identifier") return null;
213
+ const name = identifierName(expression);
214
+ if (name === undefined) return null;
215
+ const declared =
216
+ functionNode(localInitializer(file.program, name)) ??
217
+ functionNode(findDeclaration(file.program, name));
218
+ if (declared !== undefined) return { key: file.path + "#" + name, file, node: declared };
219
+ const binding = graph.resolveBinding(file.path, name);
220
+ if (binding === null || binding.file.startsWith("node_modules/")) return null;
221
+ const target = graph.file(binding.file);
222
+ if (target === undefined) return null;
223
+ const found =
224
+ functionNode(localInitializer(target.program, binding.name)) ??
225
+ functionNode(findDeclaration(target.program, binding.name));
226
+ if (found === undefined) return null;
227
+ return { key: target.path + "#" + binding.name, file: target, node: found };
228
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * D-157 diagnostics derived from a route's relation-usage fact:
3
+ *
4
+ * - route-table-undeclared (error): used and not declared in dbTables;
5
+ * - route-table-unused (warning): declared and not used — suppressed when a project-external
6
+ * callee received the client or db.execute is present, where the declaration stays
7
+ * authoritative (the runtime guard is the enforcement);
8
+ * - route-table-unresolved (error): dynamic/unresolvable relation expression;
9
+ * - route-raw-sql (error): db.execute without rawSql: true;
10
+ * - route-elevation-undeclared (error): used inside an elevation outside its tables option.
11
+ */
12
+
13
+ import { error, warning } from "../diagnostics.ts";
14
+ import type { RouteRelationsFact } from "../types-relations.ts";
15
+ import type { Diagnostic, RouteFact } from "../types.ts";
16
+
17
+ function usedNames(usage: RouteRelationsFact): ReadonlySet<string> {
18
+ const names = new Set(usage.used.map((use) => use.name));
19
+ for (const elevation of usage.elevations) {
20
+ for (const use of elevation.used) names.add(use.name);
21
+ }
22
+ return names;
23
+ }
24
+
25
+ function unusedSuppressed(usage: RouteRelationsFact): boolean {
26
+ return (
27
+ usage.opaque ||
28
+ usage.rawSql.length > 0 ||
29
+ usage.elevations.some((elevation) => elevation.opaque || elevation.tablesUnknown)
30
+ );
31
+ }
32
+
33
+ function undeclared(usage: RouteRelationsFact, declarations: ReadonlySet<string>): Diagnostic[] {
34
+ const diagnostics: Diagnostic[] = [];
35
+ for (const use of usage.used) {
36
+ if (declarations.has(use.name)) continue;
37
+ diagnostics.push(
38
+ error(
39
+ "route-table-undeclared",
40
+ 'relation "' + use.name + '" is used through the route db but not declared in dbTables',
41
+ use.source,
42
+ ),
43
+ );
44
+ }
45
+ for (const elevation of usage.elevations) {
46
+ for (const use of elevation.used) {
47
+ if (declarations.has(use.name)) continue;
48
+ diagnostics.push(
49
+ error(
50
+ "route-table-undeclared",
51
+ 'relation "' + use.name + '" is used inside an elevation but not declared in dbTables',
52
+ use.source,
53
+ ),
54
+ );
55
+ }
56
+ }
57
+ return diagnostics;
58
+ }
59
+
60
+ /** Every diagnostic the relation pass derives from one route's usage fact. */
61
+ export function routeRelationDiagnostics(
62
+ route: RouteFact,
63
+ usage: RouteRelationsFact,
64
+ ): Diagnostic[] {
65
+ const diagnostics: Diagnostic[] = [];
66
+ const declared = route.dbTables === null ? null : new Set(route.dbTables);
67
+ if (declared !== null) diagnostics.push(...undeclared(usage, declared));
68
+ for (const source of usage.unresolved) {
69
+ diagnostics.push(
70
+ error(
71
+ "route-table-unresolved",
72
+ "a relation used through the route db cannot be resolved statically",
73
+ source,
74
+ ),
75
+ );
76
+ }
77
+ for (const source of usage.rawSql) {
78
+ if (route.rawSql === true) continue;
79
+ diagnostics.push(
80
+ error("route-raw-sql", "db.execute() requires rawSql: true on the route config", source),
81
+ );
82
+ }
83
+ for (const elevation of usage.elevations) {
84
+ if (elevation.tablesUnknown || elevation.opaque) continue;
85
+ const tables = new Set(elevation.tables);
86
+ for (const use of elevation.used) {
87
+ if (tables.has(use.name)) continue;
88
+ diagnostics.push(
89
+ error(
90
+ "route-elevation-undeclared",
91
+ 'relation "' +
92
+ use.name +
93
+ '" is used inside a "' +
94
+ elevation.kind +
95
+ '" elevation but not declared in its tables option',
96
+ use.source,
97
+ ),
98
+ );
99
+ }
100
+ }
101
+ if (declared !== null && !unusedSuppressed(usage)) {
102
+ const used = usedNames(usage);
103
+ for (const name of route.dbTables ?? []) {
104
+ if (used.has(name)) continue;
105
+ diagnostics.push(
106
+ warning(
107
+ "route-table-unused",
108
+ 'dbTables declares "' + name + '" but the route never uses it',
109
+ route.source,
110
+ ),
111
+ );
112
+ }
113
+ }
114
+ return diagnostics;
115
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Interprocedural part of the route-relations pass (D-157): follow a project-local function
3
+ * with argument mapping, and enter an audited elevation callback with its elevated client.
4
+ *
5
+ * Package calls stay opaque: the runtime guard is the authority there. Following is bounded
6
+ * by MAX_DEPTH (walk) and guarded by `scope.stack`, so recursion with a db client terminates.
7
+ */
8
+
9
+ import {
10
+ asNode,
11
+ callArguments,
12
+ identifierName,
13
+ memberName,
14
+ nodeArray,
15
+ unwrapExpression,
16
+ type AstNode,
17
+ } from "../ast/node.ts";
18
+ import { sourceRef } from "../source.ts";
19
+ import type { ElevationKind, SourceRef } from "../types.ts";
20
+ import type { DbClient, LocalFunction } from "./route-relation-resolve.ts";
21
+ import { clientOf } from "./route-relation-resolve.ts";
22
+ import type { MutableElevation, RelationScope } from "./route-relations-usage.ts";
23
+ import { readStaticObject } from "./static-read.ts";
24
+
25
+ export type Visit = (node: AstNode, scope: RelationScope) => void;
26
+
27
+ function refOf(scope: RelationScope, node: AstNode): SourceRef {
28
+ return sourceRef(
29
+ scope.file.path,
30
+ scope.file.source,
31
+ typeof node.start === "number" ? node.start : 0,
32
+ );
33
+ }
34
+
35
+ function stringArray(value: unknown): string[] | null {
36
+ if (!Array.isArray(value)) return null;
37
+ return value.every((entry): entry is string => typeof entry === "string") ? [...value] : null;
38
+ }
39
+
40
+ /** Follow a project-local call, mapping db clients, elevation handlers and relation bindings. */
41
+ export function followFunction(
42
+ visit: Visit,
43
+ node: AstNode,
44
+ scope: RelationScope,
45
+ local: LocalFunction,
46
+ ): void {
47
+ if (scope.stack.has(local.key)) return;
48
+ const args = callArguments(node);
49
+ // Usage inside the arguments belongs to the caller (e.g. a query handed to a helper).
50
+ for (const argument of args) visit(argument, scope);
51
+ const env = new Map(scope.env);
52
+ const aliases = new Map(scope.aliases);
53
+ const elevationFns = new Map(scope.elevationFns);
54
+ for (const [position, param] of nodeArray(local.node, "params").entries()) {
55
+ const name = identifierName(param);
56
+ const argument = args[position];
57
+ if (name === undefined || argument === undefined) continue;
58
+ const client = clientOf(argument, scope.env, scope.contextName);
59
+ if (client !== null) {
60
+ env.set(name, client);
61
+ continue;
62
+ }
63
+ const kind = elevationKindOf(argument, scope);
64
+ if (kind !== null) {
65
+ elevationFns.set(name, kind);
66
+ continue;
67
+ }
68
+ const resolution = scope.index.resolve(scope.graph, scope.file, argument, scope.aliases);
69
+ if (resolution.kind === "resolved") aliases.set(name, resolution.name);
70
+ }
71
+ const body = asNode(local.node.body);
72
+ if (body === undefined) return;
73
+ visit(body, {
74
+ ...scope,
75
+ file: local.file,
76
+ env,
77
+ aliases,
78
+ elevationFns,
79
+ contextName: null,
80
+ depth: scope.depth + 1,
81
+ stack: new Set([...scope.stack, local.key]),
82
+ });
83
+ }
84
+
85
+ /** An argument that denotes `ctx.sudo`/`ctx.system` (destructured or forwarded). */
86
+ function elevationKindOf(argument: AstNode, scope: RelationScope): ElevationKind | null {
87
+ const expression = unwrapExpression(argument);
88
+ if (expression?.type === "Identifier") {
89
+ return scope.elevationFns.get(identifierName(expression) ?? "") ?? null;
90
+ }
91
+ if (expression?.type !== "MemberExpression") return null;
92
+ const name = memberName(expression);
93
+ const kind = name === "sudo" || name === "system" ? name : null;
94
+ if (kind === null) return null;
95
+ const object = unwrapExpression(expression.object);
96
+ return object?.type === "Identifier" && identifierName(object) === scope.contextName
97
+ ? kind
98
+ : null;
99
+ }
100
+
101
+ /** Enter an elevation callback with its elevated client bound to the first parameter. */
102
+ export function handleElevation(
103
+ visit: Visit,
104
+ node: AstNode,
105
+ scope: RelationScope,
106
+ kind: ElevationKind,
107
+ ): boolean {
108
+ const options = readStaticObject(
109
+ callArguments(node)[0],
110
+ { graph: scope.graph, file: scope.file.path },
111
+ { code: "route-relations-static", report: false },
112
+ );
113
+ const field = options.fields.get("tables");
114
+ const read = field !== undefined && field.ok ? stringArray(field.value) : null;
115
+ const tables: string[] = read ?? [];
116
+ const tablesUnknown = read === null;
117
+ const elevation: MutableElevation = {
118
+ kind,
119
+ source: refOf(scope, node),
120
+ tables,
121
+ tablesUnknown,
122
+ used: new Map(),
123
+ unresolved: [],
124
+ opaque: false,
125
+ };
126
+ const index = scope.usage.elevations.push(elevation) - 1;
127
+ const callback = unwrapExpression(callArguments(node)[1]);
128
+ if (
129
+ callback === undefined ||
130
+ (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")
131
+ ) {
132
+ // A callback we cannot inspect leaves the elevated usage unknown, never guessed.
133
+ elevation.opaque = true;
134
+ return false;
135
+ }
136
+ const body = asNode(callback.body);
137
+ if (body === undefined) {
138
+ elevation.opaque = true;
139
+ return false;
140
+ }
141
+ const env = new Map<string, DbClient>(scope.env);
142
+ const param = identifierName(nodeArray(callback, "params")[0]);
143
+ if (param !== undefined) env.set(param, { elevation: index });
144
+ visit(body, { ...scope, env, elevation: index, depth: scope.depth + 1 });
145
+ return true;
146
+ }