@facilis.dev/analyze 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) 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 +158 -0
  19. package/dist/index.d.ts +23 -0
  20. package/dist/index.js +15 -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.d.ts +22 -0
  46. package/dist/passes/jsx.js +89 -0
  47. package/dist/passes/path-expression.d.ts +26 -0
  48. package/dist/passes/path-expression.js +265 -0
  49. package/dist/passes/route-config.d.ts +24 -0
  50. package/dist/passes/route-config.js +152 -0
  51. package/dist/passes/route-description.d.ts +13 -0
  52. package/dist/passes/route-description.js +23 -0
  53. package/dist/passes/route-elevation.d.ts +18 -0
  54. package/dist/passes/route-elevation.js +136 -0
  55. package/dist/passes/route-handler.d.ts +27 -0
  56. package/dist/passes/route-handler.js +87 -0
  57. package/dist/passes/route-location.d.ts +16 -0
  58. package/dist/passes/route-location.js +58 -0
  59. package/dist/passes/route-relation-resolve.d.ts +51 -0
  60. package/dist/passes/route-relation-resolve.js +172 -0
  61. package/dist/passes/route-relations-diagnostics.d.ts +15 -0
  62. package/dist/passes/route-relations-diagnostics.js +79 -0
  63. package/dist/passes/route-relations-follow.d.ts +16 -0
  64. package/dist/passes/route-relations-follow.js +116 -0
  65. package/dist/passes/route-relations-usage.d.ts +50 -0
  66. package/dist/passes/route-relations-usage.js +44 -0
  67. package/dist/passes/route-relations-walk.d.ts +25 -0
  68. package/dist/passes/route-relations-walk.js +199 -0
  69. package/dist/passes/route-relations.d.ts +17 -0
  70. package/dist/passes/route-relations.js +43 -0
  71. package/dist/passes/route-table.d.ts +21 -0
  72. package/dist/passes/route-table.js +104 -0
  73. package/dist/passes/route-tables.d.ts +10 -0
  74. package/dist/passes/route-tables.js +42 -0
  75. package/dist/passes/routes.d.ts +18 -0
  76. package/dist/passes/routes.js +152 -0
  77. package/dist/passes/schema-column-types.d.ts +9 -0
  78. package/dist/passes/schema-column-types.js +55 -0
  79. package/dist/passes/schema-columns.d.ts +35 -0
  80. package/dist/passes/schema-columns.js +197 -0
  81. package/dist/passes/schema-dsl.d.ts +24 -0
  82. package/dist/passes/schema-dsl.js +117 -0
  83. package/dist/passes/schema-file-links-ddl.d.ts +18 -0
  84. package/dist/passes/schema-file-links-ddl.js +106 -0
  85. package/dist/passes/schema-file-links.d.ts +19 -0
  86. package/dist/passes/schema-file-links.js +144 -0
  87. package/dist/passes/schema-foreign-keys.d.ts +21 -0
  88. package/dist/passes/schema-foreign-keys.js +80 -0
  89. package/dist/passes/schema-policies.d.ts +15 -0
  90. package/dist/passes/schema-policies.js +201 -0
  91. package/dist/passes/schema-re-exports.d.ts +5 -0
  92. package/dist/passes/schema-re-exports.js +77 -0
  93. package/dist/passes/schema-relations.d.ts +8 -0
  94. package/dist/passes/schema-relations.js +182 -0
  95. package/dist/passes/schema-scope.d.ts +7 -0
  96. package/dist/passes/schema-scope.js +20 -0
  97. package/dist/passes/schema-tables.d.ts +13 -0
  98. package/dist/passes/schema-tables.js +149 -0
  99. package/dist/passes/schema.d.ts +9 -0
  100. package/dist/passes/schema.js +55 -0
  101. package/dist/passes/screen-components.d.ts +22 -0
  102. package/dist/passes/screen-components.js +143 -0
  103. package/dist/passes/screen-preview.d.ts +29 -0
  104. package/dist/passes/screen-preview.js +159 -0
  105. package/dist/passes/screens.d.ts +12 -0
  106. package/dist/passes/screens.js +278 -0
  107. package/dist/passes/static-read.d.ts +47 -0
  108. package/dist/passes/static-read.js +151 -0
  109. package/dist/passes/views.d.ts +20 -0
  110. package/dist/passes/views.js +101 -0
  111. package/dist/passes/workflow-tables.d.ts +18 -0
  112. package/dist/passes/workflow-tables.js +71 -0
  113. package/dist/passes/workflows.d.ts +8 -0
  114. package/dist/passes/workflows.js +160 -0
  115. package/dist/path-resolver.d.ts +20 -0
  116. package/dist/path-resolver.js +134 -0
  117. package/dist/revision.d.ts +2 -0
  118. package/dist/revision.js +23 -0
  119. package/dist/source.d.ts +4 -0
  120. package/dist/source.js +13 -0
  121. package/dist/static-eval.d.ts +36 -0
  122. package/dist/static-eval.js +215 -0
  123. package/dist/types-graph.d.ts +130 -0
  124. package/dist/types-graph.js +4 -0
  125. package/dist/types-jobs.d.ts +28 -0
  126. package/dist/types-jobs.js +14 -0
  127. package/dist/types-relations.d.ts +49 -0
  128. package/dist/types-relations.js +11 -0
  129. package/dist/types-workflows.d.ts +36 -0
  130. package/dist/types-workflows.js +3 -0
  131. package/dist/types.d.ts +226 -0
  132. package/dist/types.js +1 -0
  133. package/package.json +49 -4
  134. package/src/analyze.ts +119 -0
  135. package/src/ast/node.ts +102 -0
  136. package/src/ast/walk.ts +37 -0
  137. package/src/diagnostics.ts +22 -0
  138. package/src/fs.ts +59 -0
  139. package/src/graph-edges.ts +167 -0
  140. package/src/graph-ids.ts +31 -0
  141. package/src/graph.ts +190 -0
  142. package/src/index.ts +24 -0
  143. package/src/json.ts +34 -0
  144. package/src/module-graph.ts +117 -0
  145. package/src/module-info.ts +162 -0
  146. package/src/node.ts +54 -0
  147. package/src/parser.ts +19 -0
  148. package/src/passes/clients.ts +233 -0
  149. package/src/passes/config.ts +278 -0
  150. package/src/passes/file-facts.ts +142 -0
  151. package/src/passes/i18n.ts +66 -0
  152. package/src/passes/job-relations-diagnostics.ts +60 -0
  153. package/src/passes/job-relations.ts +76 -0
  154. package/src/passes/jobs.ts +181 -0
  155. package/src/passes/jsx.ts +92 -0
  156. package/src/passes/path-expression.ts +297 -0
  157. package/src/passes/route-config.ts +188 -0
  158. package/src/passes/route-description.ts +33 -0
  159. package/src/passes/route-elevation.ts +161 -0
  160. package/src/passes/route-handler.ts +105 -0
  161. package/src/passes/route-location.ts +62 -0
  162. package/src/passes/route-relation-resolve.ts +228 -0
  163. package/src/passes/route-relations-diagnostics.ts +115 -0
  164. package/src/passes/route-relations-follow.ts +146 -0
  165. package/src/passes/route-relations-usage.ts +98 -0
  166. package/src/passes/route-relations-walk.ts +255 -0
  167. package/src/passes/route-relations.ts +62 -0
  168. package/src/passes/route-table.ts +123 -0
  169. package/src/passes/route-tables.ts +50 -0
  170. package/src/passes/routes.ts +188 -0
  171. package/src/passes/schema-column-types.ts +62 -0
  172. package/src/passes/schema-columns.ts +287 -0
  173. package/src/passes/schema-dsl.ts +136 -0
  174. package/src/passes/schema-file-links-ddl.ts +125 -0
  175. package/src/passes/schema-file-links.ts +213 -0
  176. package/src/passes/schema-foreign-keys.ts +121 -0
  177. package/src/passes/schema-policies.ts +270 -0
  178. package/src/passes/schema-re-exports.ts +95 -0
  179. package/src/passes/schema-relations.ts +239 -0
  180. package/src/passes/schema-scope.ts +29 -0
  181. package/src/passes/schema-tables.ts +216 -0
  182. package/src/passes/schema.ts +67 -0
  183. package/src/passes/screen-components.ts +162 -0
  184. package/src/passes/screen-preview.ts +268 -0
  185. package/src/passes/screens.ts +291 -0
  186. package/src/passes/static-read.ts +196 -0
  187. package/src/passes/views.ts +125 -0
  188. package/src/passes/workflow-tables.ts +87 -0
  189. package/src/passes/workflows.ts +213 -0
  190. package/src/path-resolver.ts +146 -0
  191. package/src/revision.ts +25 -0
  192. package/src/source.ts +15 -0
  193. package/src/static-eval.ts +242 -0
  194. package/src/types-graph.ts +161 -0
  195. package/src/types-jobs.ts +40 -0
  196. package/src/types-relations.ts +64 -0
  197. package/src/types-workflows.ts +42 -0
  198. package/src/types.ts +299 -0
@@ -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
+ }
@@ -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
+ }