@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,278 @@
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
+
10
+ import { callArguments, identifierName, unwrapExpression, type AstNode } from "../ast/node.ts";
11
+ import { error } from "../diagnostics.ts";
12
+ import { defaultExportDeclaration } from "../module-info.ts";
13
+ import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
14
+ import { sourceRef } from "../source.ts";
15
+ import type { StaticContext } from "../static-eval.ts";
16
+ import type { ConfigFacts, Diagnostic, SourceRef, UnknownFact } from "../types.ts";
17
+ import {
18
+ defaultExportExpression,
19
+ isLocallyDeclared,
20
+ isRecord,
21
+ projectPath,
22
+ readStaticObject,
23
+ refOf,
24
+ sortDiagnostics,
25
+ type StaticObject,
26
+ } from "./static-read.ts";
27
+
28
+ const CONFIG_FILE = "facilis.config.ts";
29
+ const CORE_SPECIFIER = "@facilis.dev/core";
30
+ // Mirrors packages/core/src/config.ts. The analyzer is browser-safe and must not import the
31
+ // server package, so the defaults are restated here and covered by a test.
32
+ const DEFAULT_MIGRATIONS_DIR = "./drizzle";
33
+ const DEFAULT_TENANT_COLUMNS = ["tenantId"];
34
+ const DEFAULT_OWNER_COLUMNS = ["ownerId"];
35
+ const DEFAULT_LOCALE = "en";
36
+ const DEFAULT_LOCALES = ["en"];
37
+
38
+ export interface ConfigResult {
39
+ readonly facts: ConfigFacts;
40
+ readonly diagnostics: readonly Diagnostic[];
41
+ }
42
+
43
+ function configDefaults(): ConfigFacts {
44
+ return {
45
+ parsed: false,
46
+ source: null,
47
+ permissions: [],
48
+ roles: {},
49
+ defaultRole: null,
50
+ defaultLocale: DEFAULT_LOCALE,
51
+ locales: [...DEFAULT_LOCALES],
52
+ migrationsDir: DEFAULT_MIGRATIONS_DIR,
53
+ tenantColumns: [...DEFAULT_TENANT_COLUMNS],
54
+ ownerColumns: [...DEFAULT_OWNER_COLUMNS],
55
+ unknown: [],
56
+ };
57
+ }
58
+
59
+ export function emptyConfigFacts(): ConfigFacts {
60
+ return configDefaults();
61
+ }
62
+
63
+ function stringField(
64
+ section: Record<string, unknown>,
65
+ key: string,
66
+ fallback: string | null,
67
+ unknown: UnknownFact[],
68
+ at: SourceRef,
69
+ ): string | null {
70
+ if (!Object.hasOwn(section, key)) return fallback;
71
+ if (typeof section[key] === "string") return section[key];
72
+ unknown.push({ code: "config-static", reason: key + " must be a string", source: at });
73
+ return fallback;
74
+ }
75
+
76
+ function stringArrayField(
77
+ section: Record<string, unknown>,
78
+ key: string,
79
+ fallback: readonly string[],
80
+ unknown: UnknownFact[],
81
+ at: SourceRef,
82
+ ): readonly string[] {
83
+ if (!Object.hasOwn(section, key)) return fallback;
84
+ const value = section[key];
85
+ if (Array.isArray(value) && value.every((item): item is string => typeof item === "string")) {
86
+ return [...value];
87
+ }
88
+ unknown.push({ code: "config-static", reason: key + " must be a string[]", source: at });
89
+ return fallback;
90
+ }
91
+
92
+ function rolesField(
93
+ section: Record<string, unknown>,
94
+ unknown: UnknownFact[],
95
+ at: SourceRef,
96
+ ): Readonly<Record<string, readonly string[]>> {
97
+ if (!Object.hasOwn(section, "roles")) return {};
98
+ const value = section.roles;
99
+ if (!isRecord(value)) {
100
+ unknown.push({ code: "config-static", reason: "roles must be an object", source: at });
101
+ return {};
102
+ }
103
+ const roles: Record<string, readonly string[]> = {};
104
+ for (const [role, permissions] of Object.entries(value)) {
105
+ if (Array.isArray(permissions) && permissions.every((item) => typeof item === "string")) {
106
+ roles[role] = [...(permissions as string[])];
107
+ } else {
108
+ unknown.push({
109
+ code: "config-static",
110
+ reason: 'roles["' + role + '"] must be a string[]',
111
+ source: at,
112
+ });
113
+ }
114
+ }
115
+ return roles;
116
+ }
117
+
118
+ function readSection(
119
+ root: StaticObject,
120
+ name: string,
121
+ context: StaticContext,
122
+ unknown: UnknownFact[],
123
+ ): Record<string, unknown> | null {
124
+ const field = root.fields.get(name);
125
+ if (field === undefined) return null;
126
+ if (field.ok && isRecord(field.value)) return field.value;
127
+ const nested = readStaticObject(field.node, context, { code: "config-static", report: true });
128
+ unknown.push(...nested.unknown);
129
+ if (field.ok && nested.fields.size === 0) {
130
+ unknown.push({
131
+ code: "config-static",
132
+ reason: name + " must be an object",
133
+ source: refOf(context, field.node),
134
+ });
135
+ }
136
+ const out: Record<string, unknown> = {};
137
+ for (const [key, value] of nested.fields) if (value.ok) out[key] = value.value;
138
+ return out;
139
+ }
140
+
141
+ /** The default export must be a `defineConfig(...)` call of the @facilis.dev/core named import. */
142
+ function resolveDefineConfig(file: ProjectFile): { call: AstNode | null; reason: string | null } {
143
+ const expression = defaultExportExpression(file);
144
+ if (expression === undefined) {
145
+ return { call: null, reason: "facilis.config.ts has no default export" };
146
+ }
147
+ if (expression.type !== "CallExpression") {
148
+ return { call: null, reason: "the default export is not a defineConfig() call" };
149
+ }
150
+ const callee = unwrapExpression(expression.callee);
151
+ const name = callee?.type === "Identifier" ? identifierName(callee) : undefined;
152
+ if (name === undefined) return { call: null, reason: "defineConfig must be called by name" };
153
+ if (isLocallyDeclared(file, name)) {
154
+ return { call: null, reason: 'defineConfig "' + name + '" is shadowed by a local declaration' };
155
+ }
156
+ const imported = file.module.imports.find((entry) => entry.local === name);
157
+ if (imported === undefined) {
158
+ return { call: null, reason: "defineConfig is not imported from @facilis.dev/core" };
159
+ }
160
+ if (imported.specifier !== CORE_SPECIFIER || imported.imported !== "defineConfig") {
161
+ return { call: null, reason: "defineConfig must be the named import from @facilis.dev/core" };
162
+ }
163
+ return { call: expression, reason: null };
164
+ }
165
+
166
+ function validationIssues(
167
+ facts: ConfigFacts,
168
+ authMissing: boolean,
169
+ ): {
170
+ auth: string[];
171
+ i18n: string[];
172
+ } {
173
+ const auth: string[] = [];
174
+ if (authMissing) auth.push("auth is required in facilis.config.ts");
175
+ if (facts.permissions.includes("*")) auth.push('"*" is only valid inside auth.roles');
176
+ const seenPermissions = new Set<string>();
177
+ for (const permission of facts.permissions) {
178
+ if (seenPermissions.has(permission)) auth.push('duplicate permission "' + permission + '"');
179
+ seenPermissions.add(permission);
180
+ }
181
+ for (const [role, permissions] of Object.entries(facts.roles)) {
182
+ const seen = new Set<string>();
183
+ for (const permission of permissions) {
184
+ if (permission !== "*" && !seenPermissions.has(permission)) {
185
+ auth.push('role "' + role + '" references undeclared permission "' + permission + '"');
186
+ }
187
+ if (seen.has(permission)) auth.push('role "' + role + '" repeats "' + permission + '"');
188
+ seen.add(permission);
189
+ }
190
+ }
191
+ if (facts.defaultRole === null) {
192
+ if (!authMissing) auth.push("auth.defaultRole is required");
193
+ } else if (!Object.hasOwn(facts.roles, facts.defaultRole)) {
194
+ auth.push('defaultRole "' + facts.defaultRole + '" is not a key of auth.roles');
195
+ }
196
+ const i18n: string[] = [];
197
+ if (!facts.locales.includes(facts.defaultLocale ?? "")) {
198
+ i18n.push('defaultLocale "' + (facts.defaultLocale ?? "") + '" must be listed in i18n.locales');
199
+ }
200
+ return { auth, i18n };
201
+ }
202
+
203
+ export function buildConfigFacts(graph: ModuleGraph): ConfigResult {
204
+ const file = graph.file(projectPath(graph.root, CONFIG_FILE));
205
+ if (file === undefined) {
206
+ return {
207
+ facts: emptyConfigFacts(),
208
+ diagnostics: [
209
+ error(
210
+ "config-missing",
211
+ CONFIG_FILE + " was not found; the project config is required",
212
+ null,
213
+ ),
214
+ ],
215
+ };
216
+ }
217
+ const context: StaticContext = { graph, file: file.path };
218
+ const at = sourceRef(file.path, file.source, defaultExportDeclaration(file.program)?.start ?? 0);
219
+ const resolved = resolveDefineConfig(file);
220
+ if (resolved.call === null) {
221
+ const reason = resolved.reason ?? "defineConfig() could not be resolved";
222
+ return {
223
+ facts: {
224
+ ...configDefaults(),
225
+ source: at,
226
+ unknown: [{ code: "config-static", reason, source: at }],
227
+ },
228
+ diagnostics: [error("config-static", reason, at)],
229
+ };
230
+ }
231
+
232
+ const root = readStaticObject(callArguments(resolved.call)[0], context, {
233
+ code: "config-static",
234
+ report: false,
235
+ });
236
+ const unknown: UnknownFact[] = [...root.unknown];
237
+ const auth = readSection(root, "auth", context, unknown);
238
+ const i18n = readSection(root, "i18n", context, unknown);
239
+ const db = readSection(root, "db", context, unknown);
240
+ const authRef = refOf(context, root.fields.get("auth")?.node);
241
+ const i18nRef = refOf(context, root.fields.get("i18n")?.node);
242
+ const base = configDefaults();
243
+ const facts: ConfigFacts = {
244
+ parsed: true,
245
+ source: at,
246
+ permissions: auth === null ? [] : stringArrayField(auth, "permissions", [], unknown, authRef),
247
+ roles: auth === null ? {} : rolesField(auth, unknown, authRef),
248
+ defaultRole: auth === null ? null : stringField(auth, "defaultRole", null, unknown, authRef),
249
+ defaultLocale:
250
+ i18n === null
251
+ ? base.defaultLocale
252
+ : stringField(i18n, "defaultLocale", base.defaultLocale, unknown, i18nRef),
253
+ locales:
254
+ i18n === null
255
+ ? base.locales
256
+ : stringArrayField(i18n, "locales", base.locales, unknown, i18nRef),
257
+ migrationsDir:
258
+ db === null
259
+ ? base.migrationsDir
260
+ : (stringField(db, "migrationsDir", base.migrationsDir, unknown, at) ?? base.migrationsDir),
261
+ tenantColumns:
262
+ db === null
263
+ ? base.tenantColumns
264
+ : stringArrayField(db, "tenantColumns", base.tenantColumns, unknown, at),
265
+ ownerColumns:
266
+ db === null
267
+ ? base.ownerColumns
268
+ : stringArrayField(db, "ownerColumns", base.ownerColumns, unknown, at),
269
+ unknown,
270
+ };
271
+ const issues = validationIssues(facts, auth === null);
272
+ const diagnostics = [
273
+ ...unknown.map((fact) => error(fact.code, fact.reason, fact.source)),
274
+ ...issues.auth.map((message) => error("config-invalid", message, authRef)),
275
+ ...issues.i18n.map((message) => error("config-invalid", message, i18nRef)),
276
+ ];
277
+ return { facts, diagnostics: sortDiagnostics(diagnostics) };
278
+ }
@@ -0,0 +1,142 @@
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
+
11
+ import { identifierName, literalString, nodeArray, type AstNode } from "../ast/node.ts";
12
+ import { walk } from "../ast/walk.ts";
13
+ import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
14
+ import { sourceRef } from "../source.ts";
15
+ import type { CallFact, FileFacts, ImportFact, SourceRef, TranslationCallFact } from "../types.ts";
16
+
17
+ function refOf(file: ProjectFile, node: AstNode): SourceRef {
18
+ const offset = typeof node.start === "number" ? node.start : 0;
19
+ return sourceRef(file.path, file.source, offset);
20
+ }
21
+
22
+ function push(
23
+ imports: ImportFact[],
24
+ specifier: string,
25
+ kind: ImportFact["kind"],
26
+ local: string | null,
27
+ imported: string | null,
28
+ source: SourceRef,
29
+ ): void {
30
+ imports.push({ specifier, kind, local, imported, source });
31
+ }
32
+
33
+ function declarationImports(file: ProjectFile): ImportFact[] {
34
+ const imports: ImportFact[] = [];
35
+ for (const statement of nodeArray(file.program, "body")) {
36
+ if (statement.type === "ImportDeclaration") {
37
+ const specifier = literalString(statement.source);
38
+ if (specifier === undefined) continue;
39
+ const source = refOf(file, statement);
40
+ const specifiers = nodeArray(statement, "specifiers");
41
+ if (specifiers.length === 0) {
42
+ push(imports, specifier, "side-effect", null, null, source);
43
+ continue;
44
+ }
45
+ for (const item of specifiers) {
46
+ const local = identifierName(item.local);
47
+ if (local === undefined) continue;
48
+ if (item.type === "ImportDefaultSpecifier") {
49
+ push(imports, specifier, "default", local, "default", source);
50
+ } else if (item.type === "ImportNamespaceSpecifier") {
51
+ push(imports, specifier, "namespace", local, null, source);
52
+ } else if (item.type === "ImportSpecifier") {
53
+ const imported = identifierName(item.imported) ?? literalString(item.imported);
54
+ if (imported !== undefined) push(imports, specifier, "named", local, imported, source);
55
+ }
56
+ }
57
+ continue;
58
+ }
59
+ if (
60
+ statement.type === "ExportAllDeclaration" ||
61
+ (statement.type === "ExportNamedDeclaration" && statement.source !== null)
62
+ ) {
63
+ const specifier = literalString(statement.source);
64
+ if (specifier !== undefined) {
65
+ push(imports, specifier, "export-from", null, null, refOf(file, statement));
66
+ }
67
+ }
68
+ }
69
+ return imports;
70
+ }
71
+
72
+ function expressionImports(file: ProjectFile): ImportFact[] {
73
+ const imports: ImportFact[] = [];
74
+ walk(file.program, (node) => {
75
+ if (node.type === "ImportExpression") {
76
+ const specifier = literalString(node.source);
77
+ if (specifier !== undefined)
78
+ push(imports, specifier, "dynamic", null, null, refOf(file, node));
79
+ return;
80
+ }
81
+ if (node.type === "CallExpression" && identifierName(node.callee) === "require") {
82
+ const specifier = literalString(nodeArray(node, "arguments")[0]);
83
+ if (specifier !== undefined)
84
+ push(imports, specifier, "require", null, null, refOf(file, node));
85
+ }
86
+ });
87
+ return imports;
88
+ }
89
+
90
+ function templateString(node: AstNode | undefined): string | undefined {
91
+ if (node === undefined || node.type !== "TemplateLiteral") return undefined;
92
+ const quasis = nodeArray(node, "quasis");
93
+ if (quasis.length !== 1) return undefined;
94
+ const value = quasis[0]?.value;
95
+ if (typeof value !== "object" || value === null) return undefined;
96
+ const cooked = (value as { cooked?: unknown }).cooked;
97
+ if (typeof cooked === "string") return cooked;
98
+ const raw = (value as { raw?: unknown }).raw;
99
+ return typeof raw === "string" ? raw : undefined;
100
+ }
101
+
102
+ function translationCalls(file: ProjectFile): TranslationCallFact[] {
103
+ const calls: TranslationCallFact[] = [];
104
+ walk(file.program, (node) => {
105
+ if (node.type !== "CallExpression" || identifierName(node.callee) !== "t") return;
106
+ const argument = nodeArray(node, "arguments")[0];
107
+ const key = literalString(argument) ?? templateString(argument);
108
+ if (key !== undefined) calls.push({ key, source: refOf(file, node) });
109
+ });
110
+ return calls;
111
+ }
112
+
113
+ function bareCalls(file: ProjectFile): CallFact[] {
114
+ const calls: CallFact[] = [];
115
+ walk(file.program, (node) => {
116
+ if (node.type !== "CallExpression") return;
117
+ const name = identifierName(node.callee);
118
+ if (name !== undefined) calls.push({ name, source: refOf(file, node) });
119
+ });
120
+ return calls;
121
+ }
122
+
123
+ function byPosition(left: ImportFact, right: ImportFact): number {
124
+ return (
125
+ left.source.line - right.source.line ||
126
+ left.specifier.localeCompare(right.specifier) ||
127
+ left.kind.localeCompare(right.kind)
128
+ );
129
+ }
130
+
131
+ /** Read the module facts of every parsed source file, in path order. */
132
+ export function buildFileFacts(graph: ModuleGraph): readonly FileFacts[] {
133
+ const files = [...graph.files.values()].toSorted((left, right) =>
134
+ left.path.localeCompare(right.path),
135
+ );
136
+ return files.map((file) => ({
137
+ path: file.path,
138
+ imports: [...declarationImports(file), ...expressionImports(file)].toSorted(byPosition),
139
+ translationCalls: translationCalls(file),
140
+ calls: bareCalls(file),
141
+ }));
142
+ }
@@ -0,0 +1,66 @@
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
+
8
+ import { warning } from "../diagnostics.ts";
9
+ import type { ModuleGraph } from "../module-graph.ts";
10
+ import type { Diagnostic, LocaleCatalogs } from "../types.ts";
11
+ import { isRecord, relativePath, sortDiagnostics } from "./static-read.ts";
12
+
13
+ const LOCALES_DIR = "locales/";
14
+ const JSON_EXTENSION = ".json";
15
+
16
+ export interface LocaleResult {
17
+ readonly locales: LocaleCatalogs;
18
+ readonly diagnostics: readonly Diagnostic[];
19
+ }
20
+
21
+ function parseCatalog(source: string): Record<string, string> | null {
22
+ let parsed: unknown;
23
+ try {
24
+ parsed = JSON.parse(source) as unknown;
25
+ } catch {
26
+ return null;
27
+ }
28
+ if (!isRecord(parsed)) return null;
29
+ const catalog: Record<string, string> = {};
30
+ for (const [key, value] of Object.entries(parsed)) {
31
+ if (typeof value === "string") catalog[key] = value;
32
+ }
33
+ return catalog;
34
+ }
35
+
36
+ /** Read the locale JSON files from the project file map. */
37
+ export function readLocaleCatalogs(graph: ModuleGraph): LocaleResult {
38
+ const files = [...graph.allFiles.entries()]
39
+ .filter(([path]) => {
40
+ const relative = relativePath(graph.root, path);
41
+ if (!relative.startsWith(LOCALES_DIR)) return false;
42
+ const name = relative.slice(LOCALES_DIR.length);
43
+ return (
44
+ !name.includes("/") && name.endsWith(JSON_EXTENSION) && name.length > JSON_EXTENSION.length
45
+ );
46
+ })
47
+ .toSorted(([left], [right]) => left.localeCompare(right));
48
+ const locales: Record<string, Readonly<Record<string, string>>> = {};
49
+ const diagnostics: Diagnostic[] = [];
50
+ for (const [path, source] of files) {
51
+ const relative = relativePath(graph.root, path);
52
+ const name = relative.slice(LOCALES_DIR.length, -JSON_EXTENSION.length);
53
+ const catalog = parseCatalog(source);
54
+ if (catalog === null) {
55
+ diagnostics.push(
56
+ warning("i18n-catalog", "locale catalog " + relative + " is not a JSON object", {
57
+ file: path,
58
+ line: 1,
59
+ }),
60
+ );
61
+ continue;
62
+ }
63
+ locales[name] = catalog;
64
+ }
65
+ return { locales, diagnostics: sortDiagnostics(diagnostics) };
66
+ }
@@ -0,0 +1,60 @@
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
+
7
+ import { error, warning } from "../diagnostics.ts";
8
+ import type { Diagnostic } from "../types.ts";
9
+ import type { JobFact, JobRelationsFact } from "../types-jobs.ts";
10
+
11
+ function unusedSuppressed(usage: JobRelationsFact): boolean {
12
+ return usage.opaque || usage.rawSql.length > 0;
13
+ }
14
+
15
+ /** Every diagnostic the relation pass derives from one job's usage fact. */
16
+ export function jobRelationDiagnostics(job: JobFact, usage: JobRelationsFact): Diagnostic[] {
17
+ const diagnostics: Diagnostic[] = [];
18
+ const declared = job.dbTables === null ? null : new Set(job.dbTables);
19
+ if (declared !== null) {
20
+ for (const use of usage.used) {
21
+ if (declared.has(use.name)) continue;
22
+ diagnostics.push(
23
+ error(
24
+ "job-table-undeclared",
25
+ 'relation "' + use.name + '" is used through the job db but not declared in dbTables',
26
+ use.source,
27
+ ),
28
+ );
29
+ }
30
+ }
31
+ for (const source of usage.unresolved) {
32
+ diagnostics.push(
33
+ error(
34
+ "job-table-unresolved",
35
+ "a relation used through the job db cannot be resolved statically",
36
+ source,
37
+ ),
38
+ );
39
+ }
40
+ for (const source of usage.rawSql) {
41
+ if (job.rawSql === true) continue;
42
+ diagnostics.push(
43
+ error("job-raw-sql", "db.execute() requires rawSql: true on the job definition", source),
44
+ );
45
+ }
46
+ if (declared !== null && !unusedSuppressed(usage)) {
47
+ const used = new Set(usage.used.map((use) => use.name));
48
+ for (const name of job.dbTables ?? []) {
49
+ if (used.has(name)) continue;
50
+ diagnostics.push(
51
+ warning(
52
+ "job-table-unused",
53
+ 'dbTables declares "' + name + '" but the job never uses it',
54
+ job.source,
55
+ ),
56
+ );
57
+ }
58
+ }
59
+ return diagnostics;
60
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Job `run(ctx)` -> relation usage (D-157 applied to jobs). A job's `db` is the
3
+ * system-scoped client, so its usage has no elevation bucket: it is the route walk with the
4
+ * `{ db }` seed on the first parameter and elevations disabled. The walk and the name
5
+ * resolution are the route ones (`route-relations-walk.ts` / `route-relation-resolve.ts`),
6
+ * so the two passes cannot drift.
7
+ */
8
+
9
+ import { callArguments, unwrapExpression, type AstNode } from "../ast/node.ts";
10
+ import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
11
+ import type { Diagnostic, TableFact, ViewFact } from "../types.ts";
12
+ import type { JobFact, JobRelationsFact } from "../types-jobs.ts";
13
+ import { EMPTY_JOB_RELATIONS } from "../types-jobs.ts";
14
+ import { createRelationIndex } from "./route-relation-resolve.ts";
15
+ import { scanFunctionUsage, type UsageSeed } from "./route-relations-walk.ts";
16
+ import { jobRelationDiagnostics } from "./job-relations-diagnostics.ts";
17
+ import { resolveJobCall } from "./jobs.ts";
18
+ import { readStaticObject, sortDiagnostics } from "./static-read.ts";
19
+
20
+ /** A job runs as `facilis_system`: `run`'s first parameter is the context, no elevations. */
21
+ const JOB_SEED: UsageSeed = { contextParam: 0, elevations: false };
22
+
23
+ export interface JobRelationsScan {
24
+ readonly jobs: readonly JobFact[];
25
+ /** Usage per job, keyed by the job name (unique across the project). */
26
+ readonly relations: Readonly<Record<string, JobRelationsFact>>;
27
+ readonly diagnostics: readonly Diagnostic[];
28
+ }
29
+
30
+ /** The function expression of the definition's `run` property, when inspectable. */
31
+ function runFunction(graph: ModuleGraph, file: ProjectFile, call: AstNode): AstNode | undefined {
32
+ const object = readStaticObject(
33
+ callArguments(call)[0],
34
+ { graph, file: file.path },
35
+ { code: "job-definition-static", report: false },
36
+ );
37
+ const value = unwrapExpression(object.fields.get("run")?.node);
38
+ return value?.type === "ArrowFunctionExpression" || value?.type === "FunctionExpression"
39
+ ? value
40
+ : undefined;
41
+ }
42
+
43
+ function toJobRelations(usage: ReturnType<typeof scanFunctionUsage>): JobRelationsFact {
44
+ return {
45
+ used: usage.used,
46
+ unresolved: usage.unresolved,
47
+ rawSql: usage.rawSql,
48
+ opaque: usage.opaque,
49
+ };
50
+ }
51
+
52
+ /** Attach relation-usage facts to every scannable job and derive the D-157 diagnostics. */
53
+ export function buildJobRelationFacts(
54
+ graph: ModuleGraph,
55
+ jobs: readonly JobFact[],
56
+ tables: readonly TableFact[],
57
+ views: readonly ViewFact[],
58
+ ): JobRelationsScan {
59
+ const index = createRelationIndex(tables, views);
60
+ const relations: Record<string, JobRelationsFact> = {};
61
+ const diagnostics: Diagnostic[] = [];
62
+ for (const job of jobs) {
63
+ const file = graph.file(job.sourceFile);
64
+ const call = file === undefined ? null : resolveJobCall(file);
65
+ const run = file !== undefined && call !== null ? runFunction(graph, file, call) : undefined;
66
+ if (file === undefined || run === undefined) {
67
+ // The definition warning is the finding; an empty usage avoids a false unused warning.
68
+ relations[job.name] = EMPTY_JOB_RELATIONS;
69
+ continue;
70
+ }
71
+ const usage = toJobRelations(scanFunctionUsage(graph, file, run, index, JOB_SEED));
72
+ relations[job.name] = usage;
73
+ diagnostics.push(...jobRelationDiagnostics(job, usage));
74
+ }
75
+ return { jobs, relations, diagnostics: sortDiagnostics(diagnostics) };
76
+ }