@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,167 @@
1
+ /**
2
+ * Client consumers -> PageFacts and page→route edges (§3.4 of the AST DSL contract).
3
+ *
4
+ * Exactly one transport is sanctioned (`api()` from `src/lib/api.ts` by default): the
5
+ * transport export is resolved through imports (aliases and namespace imports included) and
6
+ * every other network call in `src/**` is a `client-transport` error. The transport module
7
+ * itself is the only place allowed to call `fetch`, so it is not scanned as a consumer.
8
+ */
9
+ import { callArguments, identifierName, memberName, unwrapExpression, } from "../ast/node.js";
10
+ import { walk } from "../ast/walk.js";
11
+ import { error, warning } from "../diagnostics.js";
12
+ import { extractPathExpression, matchRouteIds } from "./path-expression.js";
13
+ import { projectPath, readStaticObject, relativePath, refOf, sortDiagnostics, } from "./static-read.js";
14
+ const SRC_DIR = "src/";
15
+ const HTTP_METHODS = new Set([
16
+ "GET",
17
+ "POST",
18
+ "PUT",
19
+ "PATCH",
20
+ "DELETE",
21
+ "HEAD",
22
+ "OPTIONS",
23
+ "TRACE",
24
+ "CONNECT",
25
+ ]);
26
+ const NETWORK_CALLEES = new Set(["fetch", "axios", "ky", "got"]);
27
+ function transportBindings(graph, file, transportPath, exportName) {
28
+ const names = new Set();
29
+ const namespaces = new Set();
30
+ for (const imported of file.module.imports) {
31
+ if (graph.resolve(file.path, imported.specifier) !== transportPath)
32
+ continue;
33
+ if (imported.kind === "namespace")
34
+ namespaces.add(imported.local);
35
+ else if ((imported.imported ?? "default") === exportName)
36
+ names.add(imported.local);
37
+ }
38
+ return { names, namespaces };
39
+ }
40
+ function isTransportCall(call, bindings, exportName) {
41
+ const callee = unwrapExpression(call.callee);
42
+ const direct = identifierName(callee);
43
+ if (direct !== undefined)
44
+ return bindings.names.has(direct);
45
+ if (callee?.type !== "MemberExpression")
46
+ return false;
47
+ const objectName = identifierName(unwrapExpression(callee.object));
48
+ return (objectName !== undefined &&
49
+ bindings.namespaces.has(objectName) &&
50
+ memberName(callee) === exportName);
51
+ }
52
+ /** Name of a network call that is not the transport, or null when the node is not one. */
53
+ function networkCallName(node) {
54
+ if (node.type === "NewExpression") {
55
+ return identifierName(unwrapExpression(node.callee)) === "XMLHttpRequest"
56
+ ? "XMLHttpRequest"
57
+ : null;
58
+ }
59
+ if (node.type !== "CallExpression")
60
+ return null;
61
+ const callee = unwrapExpression(node.callee);
62
+ const direct = identifierName(callee);
63
+ if (direct !== undefined && NETWORK_CALLEES.has(direct))
64
+ return direct;
65
+ if (callee?.type !== "MemberExpression")
66
+ return null;
67
+ const objectName = identifierName(unwrapExpression(callee.object));
68
+ return objectName !== undefined && NETWORK_CALLEES.has(objectName) ? objectName : null;
69
+ }
70
+ /** The request method comes from the second argument's static `method`, else GET. */
71
+ function requestMethod(init, context, callRef) {
72
+ if (init === undefined)
73
+ return { method: "GET", unknown: null };
74
+ const object = readStaticObject(init, context, { code: "page-path-static", report: false });
75
+ const field = object.fields.get("method");
76
+ if (field !== undefined && field.ok && typeof field.value === "string") {
77
+ const method = field.value.toUpperCase();
78
+ if (HTTP_METHODS.has(method))
79
+ return { method: method, unknown: null };
80
+ }
81
+ // An unreadable options object hides the method; defaulting silently could produce a wrong
82
+ // page→route edge, so the assumption is recorded as unknown.
83
+ const unknown = field === undefined && object.unknown.length === 0
84
+ ? null
85
+ : {
86
+ code: "page-path-static",
87
+ reason: "the request method cannot be read statically; assuming GET",
88
+ source: callRef,
89
+ };
90
+ return { method: "GET", unknown };
91
+ }
92
+ function pageKind(relative) {
93
+ if (relative.startsWith("src/pages/"))
94
+ return "page";
95
+ if (relative.startsWith("src/components/"))
96
+ return "component";
97
+ if (relative.startsWith("src/lib/"))
98
+ return "lib";
99
+ return "other";
100
+ }
101
+ function scanPage(graph, file, routes, transportPath, exportName) {
102
+ const context = { graph, file: file.path };
103
+ const bindings = transportBindings(graph, file, transportPath, exportName);
104
+ const calls = [];
105
+ const diagnostics = [];
106
+ walk(file.program, (node) => {
107
+ if (node.type !== "CallExpression" && node.type !== "NewExpression")
108
+ return;
109
+ const transport = node.type === "CallExpression" && isTransportCall(node, bindings, exportName);
110
+ const network = transport ? null : networkCallName(node);
111
+ if (!transport && network === null)
112
+ return;
113
+ const callRef = refOf(context, node);
114
+ const args = node.type === "CallExpression" ? callArguments(node) : [];
115
+ const extracted = extractPathExpression(args[0], context, node, "page-path-static");
116
+ const method = requestMethod(args[1], context, callRef);
117
+ const unknown = [...extracted.unknown];
118
+ if (method.unknown !== null)
119
+ unknown.push(method.unknown);
120
+ if (transport && extracted.path === null && extracted.staticPrefix === null) {
121
+ diagnostics.push(warning("page-path-static", "the API path cannot be resolved statically", callRef));
122
+ }
123
+ if (!transport && network !== null) {
124
+ const reason = network + " outside the sanctioned transport module is not allowed";
125
+ unknown.push({ code: "client-transport", reason, source: callRef });
126
+ diagnostics.push(error("client-transport", reason, callRef));
127
+ }
128
+ calls.push({
129
+ expression: extracted.expression,
130
+ kind: extracted.kind,
131
+ staticPrefix: extracted.staticPrefix,
132
+ matchedRouteIds: matchRouteIds(routes, extracted.path, extracted.staticPrefix, method.method),
133
+ source: callRef,
134
+ unknown,
135
+ });
136
+ });
137
+ const page = {
138
+ path: file.path,
139
+ kind: pageKind(relativePath(graph.root, file.path)),
140
+ calls,
141
+ source: { file: file.path, line: 1 },
142
+ unknown: [],
143
+ };
144
+ return { page, diagnostics };
145
+ }
146
+ /** Map client files to the routes they call. */
147
+ export function buildPageFacts(graph, routes, transport) {
148
+ const transportPath = projectPath(graph.root, transport.module);
149
+ const files = [...graph.files.values()]
150
+ .filter((file) => {
151
+ const relative = relativePath(graph.root, file.path);
152
+ if (!relative.startsWith(SRC_DIR))
153
+ return false;
154
+ if (relative.includes(".test."))
155
+ return false;
156
+ return file.path !== transportPath;
157
+ })
158
+ .toSorted((left, right) => left.path.localeCompare(right.path));
159
+ const pages = [];
160
+ const diagnostics = [];
161
+ for (const file of files) {
162
+ const scanned = scanPage(graph, file, routes, transportPath, transport.exportName);
163
+ pages.push(scanned.page);
164
+ diagnostics.push(...scanned.diagnostics);
165
+ }
166
+ return { pages, diagnostics: sortDiagnostics(diagnostics) };
167
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `facilis.config.ts` -> ConfigFacts (docs/findings/ast-dsl-contract.md §3.2).
3
+ *
4
+ * The config is the project's security contract, so a value outside the static subset is an
5
+ * explicit unknown plus a diagnostic, never a silent default. Defaults mirror
6
+ * packages/core/src/config.ts exactly: the runtime and the analyzer must agree on what
7
+ * "absent" means, and only the analyzer may report a wrong value instead of throwing.
8
+ */
9
+ import type { ModuleGraph } from "../module-graph.js";
10
+ import type { ConfigFacts, Diagnostic } from "../types.js";
11
+ export interface ConfigResult {
12
+ readonly facts: ConfigFacts;
13
+ readonly diagnostics: readonly Diagnostic[];
14
+ }
15
+ export declare function emptyConfigFacts(): ConfigFacts;
16
+ export declare function buildConfigFacts(graph: ModuleGraph): ConfigResult;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * `facilis.config.ts` -> ConfigFacts (docs/findings/ast-dsl-contract.md §3.2).
3
+ *
4
+ * The config is the project's security contract, so a value outside the static subset is an
5
+ * explicit unknown plus a diagnostic, never a silent default. Defaults mirror
6
+ * packages/core/src/config.ts exactly: the runtime and the analyzer must agree on what
7
+ * "absent" means, and only the analyzer may report a wrong value instead of throwing.
8
+ */
9
+ import { callArguments, identifierName, unwrapExpression } from "../ast/node.js";
10
+ import { error } from "../diagnostics.js";
11
+ import { defaultExportDeclaration } from "../module-info.js";
12
+ import { sourceRef } from "../source.js";
13
+ import { defaultExportExpression, isLocallyDeclared, isRecord, projectPath, readStaticObject, refOf, sortDiagnostics, } from "./static-read.js";
14
+ const CONFIG_FILE = "facilis.config.ts";
15
+ const CORE_SPECIFIER = "@facilis.dev/core";
16
+ // Mirrors packages/core/src/config.ts. The analyzer is browser-safe and must not import the
17
+ // server package, so the defaults are restated here and covered by a test.
18
+ const DEFAULT_MIGRATIONS_DIR = "./drizzle";
19
+ const DEFAULT_TENANT_COLUMNS = ["tenantId"];
20
+ const DEFAULT_OWNER_COLUMNS = ["ownerId"];
21
+ const DEFAULT_LOCALE = "en";
22
+ const DEFAULT_LOCALES = ["en"];
23
+ function configDefaults() {
24
+ return {
25
+ parsed: false,
26
+ source: null,
27
+ permissions: [],
28
+ roles: {},
29
+ defaultRole: null,
30
+ defaultLocale: DEFAULT_LOCALE,
31
+ locales: [...DEFAULT_LOCALES],
32
+ migrationsDir: DEFAULT_MIGRATIONS_DIR,
33
+ tenantColumns: [...DEFAULT_TENANT_COLUMNS],
34
+ ownerColumns: [...DEFAULT_OWNER_COLUMNS],
35
+ unknown: [],
36
+ };
37
+ }
38
+ export function emptyConfigFacts() {
39
+ return configDefaults();
40
+ }
41
+ function stringField(section, key, fallback, unknown, at) {
42
+ if (!Object.hasOwn(section, key))
43
+ return fallback;
44
+ if (typeof section[key] === "string")
45
+ return section[key];
46
+ unknown.push({ code: "config-static", reason: key + " must be a string", source: at });
47
+ return fallback;
48
+ }
49
+ function stringArrayField(section, key, fallback, unknown, at) {
50
+ if (!Object.hasOwn(section, key))
51
+ return fallback;
52
+ const value = section[key];
53
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
54
+ return [...value];
55
+ }
56
+ unknown.push({ code: "config-static", reason: key + " must be a string[]", source: at });
57
+ return fallback;
58
+ }
59
+ function rolesField(section, unknown, at) {
60
+ if (!Object.hasOwn(section, "roles"))
61
+ return {};
62
+ const value = section.roles;
63
+ if (!isRecord(value)) {
64
+ unknown.push({ code: "config-static", reason: "roles must be an object", source: at });
65
+ return {};
66
+ }
67
+ const roles = {};
68
+ for (const [role, permissions] of Object.entries(value)) {
69
+ if (Array.isArray(permissions) && permissions.every((item) => typeof item === "string")) {
70
+ roles[role] = [...permissions];
71
+ }
72
+ else {
73
+ unknown.push({
74
+ code: "config-static",
75
+ reason: 'roles["' + role + '"] must be a string[]',
76
+ source: at,
77
+ });
78
+ }
79
+ }
80
+ return roles;
81
+ }
82
+ function readSection(root, name, context, unknown) {
83
+ const field = root.fields.get(name);
84
+ if (field === undefined)
85
+ return null;
86
+ if (field.ok && isRecord(field.value))
87
+ return field.value;
88
+ const nested = readStaticObject(field.node, context, { code: "config-static", report: true });
89
+ unknown.push(...nested.unknown);
90
+ if (field.ok && nested.fields.size === 0) {
91
+ unknown.push({
92
+ code: "config-static",
93
+ reason: name + " must be an object",
94
+ source: refOf(context, field.node),
95
+ });
96
+ }
97
+ const out = {};
98
+ for (const [key, value] of nested.fields)
99
+ if (value.ok)
100
+ out[key] = value.value;
101
+ return out;
102
+ }
103
+ /** The default export must be a `defineConfig(...)` call of the @facilis.dev/core named import. */
104
+ function resolveDefineConfig(file) {
105
+ const expression = defaultExportExpression(file);
106
+ if (expression === undefined) {
107
+ return { call: null, reason: "facilis.config.ts has no default export" };
108
+ }
109
+ if (expression.type !== "CallExpression") {
110
+ return { call: null, reason: "the default export is not a defineConfig() call" };
111
+ }
112
+ const callee = unwrapExpression(expression.callee);
113
+ const name = callee?.type === "Identifier" ? identifierName(callee) : undefined;
114
+ if (name === undefined)
115
+ return { call: null, reason: "defineConfig must be called by name" };
116
+ if (isLocallyDeclared(file, name)) {
117
+ return { call: null, reason: 'defineConfig "' + name + '" is shadowed by a local declaration' };
118
+ }
119
+ const imported = file.module.imports.find((entry) => entry.local === name);
120
+ if (imported === undefined) {
121
+ return { call: null, reason: "defineConfig is not imported from @facilis.dev/core" };
122
+ }
123
+ if (imported.specifier !== CORE_SPECIFIER || imported.imported !== "defineConfig") {
124
+ return { call: null, reason: "defineConfig must be the named import from @facilis.dev/core" };
125
+ }
126
+ return { call: expression, reason: null };
127
+ }
128
+ function validationIssues(facts, authMissing) {
129
+ const auth = [];
130
+ if (authMissing)
131
+ auth.push("auth is required in facilis.config.ts");
132
+ if (facts.permissions.includes("*"))
133
+ auth.push('"*" is only valid inside auth.roles');
134
+ const seenPermissions = new Set();
135
+ for (const permission of facts.permissions) {
136
+ if (seenPermissions.has(permission))
137
+ auth.push('duplicate permission "' + permission + '"');
138
+ seenPermissions.add(permission);
139
+ }
140
+ for (const [role, permissions] of Object.entries(facts.roles)) {
141
+ const seen = new Set();
142
+ for (const permission of permissions) {
143
+ if (permission !== "*" && !seenPermissions.has(permission)) {
144
+ auth.push('role "' + role + '" references undeclared permission "' + permission + '"');
145
+ }
146
+ if (seen.has(permission))
147
+ auth.push('role "' + role + '" repeats "' + permission + '"');
148
+ seen.add(permission);
149
+ }
150
+ }
151
+ if (facts.defaultRole === null) {
152
+ if (!authMissing)
153
+ auth.push("auth.defaultRole is required");
154
+ }
155
+ else if (!Object.hasOwn(facts.roles, facts.defaultRole)) {
156
+ auth.push('defaultRole "' + facts.defaultRole + '" is not a key of auth.roles');
157
+ }
158
+ const i18n = [];
159
+ if (!facts.locales.includes(facts.defaultLocale ?? "")) {
160
+ i18n.push('defaultLocale "' + (facts.defaultLocale ?? "") + '" must be listed in i18n.locales');
161
+ }
162
+ return { auth, i18n };
163
+ }
164
+ export function buildConfigFacts(graph) {
165
+ const file = graph.file(projectPath(graph.root, CONFIG_FILE));
166
+ if (file === undefined) {
167
+ return {
168
+ facts: emptyConfigFacts(),
169
+ diagnostics: [
170
+ error("config-missing", CONFIG_FILE + " was not found; the project config is required", null),
171
+ ],
172
+ };
173
+ }
174
+ const context = { graph, file: file.path };
175
+ const at = sourceRef(file.path, file.source, defaultExportDeclaration(file.program)?.start ?? 0);
176
+ const resolved = resolveDefineConfig(file);
177
+ if (resolved.call === null) {
178
+ const reason = resolved.reason ?? "defineConfig() could not be resolved";
179
+ return {
180
+ facts: {
181
+ ...configDefaults(),
182
+ source: at,
183
+ unknown: [{ code: "config-static", reason, source: at }],
184
+ },
185
+ diagnostics: [error("config-static", reason, at)],
186
+ };
187
+ }
188
+ const root = readStaticObject(callArguments(resolved.call)[0], context, {
189
+ code: "config-static",
190
+ report: false,
191
+ });
192
+ const unknown = [...root.unknown];
193
+ const auth = readSection(root, "auth", context, unknown);
194
+ const i18n = readSection(root, "i18n", context, unknown);
195
+ const db = readSection(root, "db", context, unknown);
196
+ const authRef = refOf(context, root.fields.get("auth")?.node);
197
+ const i18nRef = refOf(context, root.fields.get("i18n")?.node);
198
+ const base = configDefaults();
199
+ const facts = {
200
+ parsed: true,
201
+ source: at,
202
+ permissions: auth === null ? [] : stringArrayField(auth, "permissions", [], unknown, authRef),
203
+ roles: auth === null ? {} : rolesField(auth, unknown, authRef),
204
+ defaultRole: auth === null ? null : stringField(auth, "defaultRole", null, unknown, authRef),
205
+ defaultLocale: i18n === null
206
+ ? base.defaultLocale
207
+ : stringField(i18n, "defaultLocale", base.defaultLocale, unknown, i18nRef),
208
+ locales: i18n === null
209
+ ? base.locales
210
+ : stringArrayField(i18n, "locales", base.locales, unknown, i18nRef),
211
+ migrationsDir: db === null
212
+ ? base.migrationsDir
213
+ : (stringField(db, "migrationsDir", base.migrationsDir, unknown, at) ?? base.migrationsDir),
214
+ tenantColumns: db === null
215
+ ? base.tenantColumns
216
+ : stringArrayField(db, "tenantColumns", base.tenantColumns, unknown, at),
217
+ ownerColumns: db === null
218
+ ? base.ownerColumns
219
+ : stringArrayField(db, "ownerColumns", base.ownerColumns, unknown, at),
220
+ unknown,
221
+ };
222
+ const issues = validationIssues(facts, auth === null);
223
+ const diagnostics = [
224
+ ...unknown.map((fact) => error(fact.code, fact.reason, fact.source)),
225
+ ...issues.auth.map((message) => error("config-invalid", message, authRef)),
226
+ ...issues.i18n.map((message) => error("config-invalid", message, i18nRef)),
227
+ ];
228
+ return { facts, diagnostics: sortDiagnostics(diagnostics) };
229
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Per-file module facts: imports, re-exports, dynamic imports/requires and literal `t()`
3
+ * keys (docs/findings/ast-dsl-contract.md §3.6).
4
+ *
5
+ * These are the AST replacement for the patch validator's tolerant text scans: the rules
6
+ * need the module graph edges and the translation keys of a source file, and they must be
7
+ * readable without executing the project. The parser produced the structure; this pass only
8
+ * reads it, so a construct outside the static subset is simply absent rather than guessed.
9
+ */
10
+ import type { ModuleGraph } from "../module-graph.js";
11
+ import type { FileFacts } from "../types.js";
12
+ /** Read the module facts of every parsed source file, in path order. */
13
+ export declare function buildFileFacts(graph: ModuleGraph): readonly FileFacts[];
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Per-file module facts: imports, re-exports, dynamic imports/requires and literal `t()`
3
+ * keys (docs/findings/ast-dsl-contract.md §3.6).
4
+ *
5
+ * These are the AST replacement for the patch validator's tolerant text scans: the rules
6
+ * need the module graph edges and the translation keys of a source file, and they must be
7
+ * readable without executing the project. The parser produced the structure; this pass only
8
+ * reads it, so a construct outside the static subset is simply absent rather than guessed.
9
+ */
10
+ import { identifierName, literalString, nodeArray } from "../ast/node.js";
11
+ import { walk } from "../ast/walk.js";
12
+ import { sourceRef } from "../source.js";
13
+ function refOf(file, node) {
14
+ const offset = typeof node.start === "number" ? node.start : 0;
15
+ return sourceRef(file.path, file.source, offset);
16
+ }
17
+ function push(imports, specifier, kind, local, imported, source) {
18
+ imports.push({ specifier, kind, local, imported, source });
19
+ }
20
+ function declarationImports(file) {
21
+ const imports = [];
22
+ for (const statement of nodeArray(file.program, "body")) {
23
+ if (statement.type === "ImportDeclaration") {
24
+ const specifier = literalString(statement.source);
25
+ if (specifier === undefined)
26
+ continue;
27
+ const source = refOf(file, statement);
28
+ const specifiers = nodeArray(statement, "specifiers");
29
+ if (specifiers.length === 0) {
30
+ push(imports, specifier, "side-effect", null, null, source);
31
+ continue;
32
+ }
33
+ for (const item of specifiers) {
34
+ const local = identifierName(item.local);
35
+ if (local === undefined)
36
+ continue;
37
+ if (item.type === "ImportDefaultSpecifier") {
38
+ push(imports, specifier, "default", local, "default", source);
39
+ }
40
+ else if (item.type === "ImportNamespaceSpecifier") {
41
+ push(imports, specifier, "namespace", local, null, source);
42
+ }
43
+ else if (item.type === "ImportSpecifier") {
44
+ const imported = identifierName(item.imported) ?? literalString(item.imported);
45
+ if (imported !== undefined)
46
+ push(imports, specifier, "named", local, imported, source);
47
+ }
48
+ }
49
+ continue;
50
+ }
51
+ if (statement.type === "ExportAllDeclaration" ||
52
+ (statement.type === "ExportNamedDeclaration" && statement.source !== null)) {
53
+ const specifier = literalString(statement.source);
54
+ if (specifier !== undefined) {
55
+ push(imports, specifier, "export-from", null, null, refOf(file, statement));
56
+ }
57
+ }
58
+ }
59
+ return imports;
60
+ }
61
+ function expressionImports(file) {
62
+ const imports = [];
63
+ walk(file.program, (node) => {
64
+ if (node.type === "ImportExpression") {
65
+ const specifier = literalString(node.source);
66
+ if (specifier !== undefined)
67
+ push(imports, specifier, "dynamic", null, null, refOf(file, node));
68
+ return;
69
+ }
70
+ if (node.type === "CallExpression" && identifierName(node.callee) === "require") {
71
+ const specifier = literalString(nodeArray(node, "arguments")[0]);
72
+ if (specifier !== undefined)
73
+ push(imports, specifier, "require", null, null, refOf(file, node));
74
+ }
75
+ });
76
+ return imports;
77
+ }
78
+ function templateString(node) {
79
+ if (node === undefined || node.type !== "TemplateLiteral")
80
+ return undefined;
81
+ const quasis = nodeArray(node, "quasis");
82
+ if (quasis.length !== 1)
83
+ return undefined;
84
+ const value = quasis[0]?.value;
85
+ if (typeof value !== "object" || value === null)
86
+ return undefined;
87
+ const cooked = value.cooked;
88
+ if (typeof cooked === "string")
89
+ return cooked;
90
+ const raw = value.raw;
91
+ return typeof raw === "string" ? raw : undefined;
92
+ }
93
+ function translationCalls(file) {
94
+ const calls = [];
95
+ walk(file.program, (node) => {
96
+ if (node.type !== "CallExpression" || identifierName(node.callee) !== "t")
97
+ return;
98
+ const argument = nodeArray(node, "arguments")[0];
99
+ const key = literalString(argument) ?? templateString(argument);
100
+ if (key !== undefined)
101
+ calls.push({ key, source: refOf(file, node) });
102
+ });
103
+ return calls;
104
+ }
105
+ function bareCalls(file) {
106
+ const calls = [];
107
+ walk(file.program, (node) => {
108
+ if (node.type !== "CallExpression")
109
+ return;
110
+ const name = identifierName(node.callee);
111
+ if (name !== undefined)
112
+ calls.push({ name, source: refOf(file, node) });
113
+ });
114
+ return calls;
115
+ }
116
+ function byPosition(left, right) {
117
+ return (left.source.line - right.source.line ||
118
+ left.specifier.localeCompare(right.specifier) ||
119
+ left.kind.localeCompare(right.kind));
120
+ }
121
+ /** Read the module facts of every parsed source file, in path order. */
122
+ export function buildFileFacts(graph) {
123
+ const files = [...graph.files.values()].toSorted((left, right) => left.path.localeCompare(right.path));
124
+ return files.map((file) => ({
125
+ path: file.path,
126
+ imports: [...declarationImports(file), ...expressionImports(file)].toSorted(byPosition),
127
+ translationCalls: translationCalls(file),
128
+ calls: bareCalls(file),
129
+ }));
130
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Locale catalogs: every `locales/*.json` in the file map (docs/findings/ast-dsl-contract.md
3
+ * §3.5). A missing `locales/` directory is fine (catalogs are optional); malformed JSON is a
4
+ * warning and the catalog is skipped, because i18n completeness is a gate concern but a broken
5
+ * file must never stop the rest of the analysis.
6
+ */
7
+ import type { ModuleGraph } from "../module-graph.js";
8
+ import type { Diagnostic, LocaleCatalogs } from "../types.js";
9
+ export interface LocaleResult {
10
+ readonly locales: LocaleCatalogs;
11
+ readonly diagnostics: readonly Diagnostic[];
12
+ }
13
+ /** Read the locale JSON files from the project file map. */
14
+ export declare function readLocaleCatalogs(graph: ModuleGraph): LocaleResult;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Locale catalogs: every `locales/*.json` in the file map (docs/findings/ast-dsl-contract.md
3
+ * §3.5). A missing `locales/` directory is fine (catalogs are optional); malformed JSON is a
4
+ * warning and the catalog is skipped, because i18n completeness is a gate concern but a broken
5
+ * file must never stop the rest of the analysis.
6
+ */
7
+ import { warning } from "../diagnostics.js";
8
+ import { isRecord, relativePath, sortDiagnostics } from "./static-read.js";
9
+ const LOCALES_DIR = "locales/";
10
+ const JSON_EXTENSION = ".json";
11
+ function parseCatalog(source) {
12
+ let parsed;
13
+ try {
14
+ parsed = JSON.parse(source);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ if (!isRecord(parsed))
20
+ return null;
21
+ const catalog = {};
22
+ for (const [key, value] of Object.entries(parsed)) {
23
+ if (typeof value === "string")
24
+ catalog[key] = value;
25
+ }
26
+ return catalog;
27
+ }
28
+ /** Read the locale JSON files from the project file map. */
29
+ export function readLocaleCatalogs(graph) {
30
+ const files = [...graph.allFiles.entries()]
31
+ .filter(([path]) => {
32
+ const relative = relativePath(graph.root, path);
33
+ if (!relative.startsWith(LOCALES_DIR))
34
+ return false;
35
+ const name = relative.slice(LOCALES_DIR.length);
36
+ return (!name.includes("/") && name.endsWith(JSON_EXTENSION) && name.length > JSON_EXTENSION.length);
37
+ })
38
+ .toSorted(([left], [right]) => left.localeCompare(right));
39
+ const locales = {};
40
+ const diagnostics = [];
41
+ for (const [path, source] of files) {
42
+ const relative = relativePath(graph.root, path);
43
+ const name = relative.slice(LOCALES_DIR.length, -JSON_EXTENSION.length);
44
+ const catalog = parseCatalog(source);
45
+ if (catalog === null) {
46
+ diagnostics.push(warning("i18n-catalog", "locale catalog " + relative + " is not a JSON object", {
47
+ file: path,
48
+ line: 1,
49
+ }));
50
+ continue;
51
+ }
52
+ locales[name] = catalog;
53
+ }
54
+ return { locales, diagnostics: sortDiagnostics(diagnostics) };
55
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * D-157 diagnostics for a job's static db usage. The rules mirror the route ones
3
+ * (`route-relations-diagnostics.ts`) with job codes; jobs have no elevation dimension, so the
4
+ * only suppression of the unused warning is an opaque helper or an allowed `db.execute`.
5
+ */
6
+ import type { Diagnostic } from "../types.js";
7
+ import type { JobFact, JobRelationsFact } from "../types-jobs.js";
8
+ /** Every diagnostic the relation pass derives from one job's usage fact. */
9
+ export declare function jobRelationDiagnostics(job: JobFact, usage: JobRelationsFact): Diagnostic[];
@@ -0,0 +1,38 @@
1
+ /**
2
+ * D-157 diagnostics for a job's static db usage. The rules mirror the route ones
3
+ * (`route-relations-diagnostics.ts`) with job codes; jobs have no elevation dimension, so the
4
+ * only suppression of the unused warning is an opaque helper or an allowed `db.execute`.
5
+ */
6
+ import { error, warning } from "../diagnostics.js";
7
+ function unusedSuppressed(usage) {
8
+ return usage.opaque || usage.rawSql.length > 0;
9
+ }
10
+ /** Every diagnostic the relation pass derives from one job's usage fact. */
11
+ export function jobRelationDiagnostics(job, usage) {
12
+ const diagnostics = [];
13
+ const declared = job.dbTables === null ? null : new Set(job.dbTables);
14
+ if (declared !== null) {
15
+ for (const use of usage.used) {
16
+ if (declared.has(use.name))
17
+ continue;
18
+ diagnostics.push(error("job-table-undeclared", 'relation "' + use.name + '" is used through the job db but not declared in dbTables', use.source));
19
+ }
20
+ }
21
+ for (const source of usage.unresolved) {
22
+ diagnostics.push(error("job-table-unresolved", "a relation used through the job db cannot be resolved statically", source));
23
+ }
24
+ for (const source of usage.rawSql) {
25
+ if (job.rawSql === true)
26
+ continue;
27
+ diagnostics.push(error("job-raw-sql", "db.execute() requires rawSql: true on the job definition", source));
28
+ }
29
+ if (declared !== null && !unusedSuppressed(usage)) {
30
+ const used = new Set(usage.used.map((use) => use.name));
31
+ for (const name of job.dbTables ?? []) {
32
+ if (used.has(name))
33
+ continue;
34
+ diagnostics.push(warning("job-table-unused", 'dbTables declares "' + name + '" but the job never uses it', job.source));
35
+ }
36
+ }
37
+ return diagnostics;
38
+ }