@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,181 @@
1
+ /**
2
+ * `server/jobs/**` definitions -> JobFacts (D-157 applied to jobs).
3
+ *
4
+ * One default export per file: a `defineJob()` call from `@facilis.dev/core`. A file that is not
5
+ * a valid definition is a warning, not an error: jobs are optional, so a broken file must not
6
+ * hide the rest of the project. `dbTables` is the declaration the runtime guard enforces, so
7
+ * an unreadable one is explicit (null) and its warning keeps the jobs gate red.
8
+ */
9
+
10
+ import { callArguments, identifierName, unwrapExpression, type AstNode } from "../ast/node.ts";
11
+ import { warning } from "../diagnostics.ts";
12
+ import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
13
+ import type { StaticContext } from "../static-eval.ts";
14
+ import type { Diagnostic, UnknownFact } from "../types.ts";
15
+ import type { JobFact } from "../types-jobs.ts";
16
+ import {
17
+ defaultExportExpression,
18
+ isLocallyDeclared,
19
+ readStaticObject,
20
+ refOf,
21
+ relativePath,
22
+ sortDiagnostics,
23
+ } from "./static-read.ts";
24
+
25
+ const CORE_SPECIFIER = "@facilis.dev/core";
26
+ const JOBS_DIR = "server/jobs/";
27
+ const STATIC_CODE = "job-definition-static";
28
+
29
+ export interface JobResult {
30
+ readonly jobs: readonly JobFact[];
31
+ readonly diagnostics: readonly Diagnostic[];
32
+ }
33
+
34
+ interface DefinitionResolution {
35
+ readonly call: AstNode | null;
36
+ readonly reason: string;
37
+ }
38
+
39
+ /** The default export must be a `defineJob(...)` call of the core import. */
40
+ function resolveDefinition(file: ProjectFile): DefinitionResolution {
41
+ const expression = defaultExportExpression(file);
42
+ if (expression === undefined) {
43
+ return { call: null, reason: "the job file has no default export" };
44
+ }
45
+ if (expression.type !== "CallExpression") {
46
+ return { call: null, reason: "the default export is not a defineJob() call" };
47
+ }
48
+ const callee = unwrapExpression(expression.callee);
49
+ const name = callee?.type === "Identifier" ? identifierName(callee) : undefined;
50
+ if (name === undefined) return { call: null, reason: "the defineJob callee cannot be resolved" };
51
+ if (isLocallyDeclared(file, name)) {
52
+ return { call: null, reason: '"' + name + '" is shadowed by a local declaration' };
53
+ }
54
+ const imported = file.module.imports.find((entry) => entry.local === name);
55
+ if (
56
+ imported === undefined ||
57
+ imported.specifier !== CORE_SPECIFIER ||
58
+ imported.imported !== "defineJob"
59
+ ) {
60
+ return {
61
+ call: null,
62
+ reason: "the default export is not the defineJob import from @facilis.dev/core",
63
+ };
64
+ }
65
+ return { call: expression, reason: "" };
66
+ }
67
+
68
+ /** The `defineJob(...)` call of a valid definition, for later passes (D-157). */
69
+ export function resolveJobCall(file: ProjectFile): AstNode | null {
70
+ return resolveDefinition(file).call;
71
+ }
72
+
73
+ function stringArray(value: unknown): string[] | null {
74
+ if (!Array.isArray(value)) return null;
75
+ return value.every((entry): entry is string => typeof entry === "string") ? [...value] : null;
76
+ }
77
+
78
+ function scanFile(
79
+ graph: ModuleGraph,
80
+ file: ProjectFile,
81
+ ): {
82
+ readonly job: JobFact | null;
83
+ readonly diagnostics: readonly Diagnostic[];
84
+ } {
85
+ const context: StaticContext = { graph, file: file.path };
86
+ const definition = resolveDefinition(file);
87
+ const ref = refOf(context, definition.call ?? defaultExportExpression(file));
88
+ if (definition.call === null) {
89
+ return { job: null, diagnostics: [warning("job-definition", definition.reason, ref)] };
90
+ }
91
+ const object = readStaticObject(callArguments(definition.call)[0], context, {
92
+ code: STATIC_CODE,
93
+ report: false,
94
+ });
95
+ // A structural read failure (spread/computed key) makes the whole definition suspect: keep
96
+ // it visible as an unknown and as a warning, as the route config reader does.
97
+ const unknown: UnknownFact[] = [...object.unknown];
98
+ const diagnostics: Diagnostic[] = object.unknown.map((item) =>
99
+ warning(item.code, item.reason, item.source),
100
+ );
101
+ const note = (reason: string, at: AstNode | undefined): void => {
102
+ const source = refOf(context, at);
103
+ unknown.push({ code: STATIC_CODE, reason, source });
104
+ diagnostics.push(warning(STATIC_CODE, reason, source));
105
+ };
106
+
107
+ const nameField = object.fields.get("name");
108
+ let name: string | null = null;
109
+ if (
110
+ nameField !== undefined &&
111
+ nameField.ok &&
112
+ typeof nameField.value === "string" &&
113
+ nameField.value.length > 0
114
+ ) {
115
+ name = nameField.value;
116
+ }
117
+ if (name === null) {
118
+ diagnostics.push(
119
+ warning(
120
+ "job-definition",
121
+ "the job name cannot be read statically",
122
+ refOf(context, nameField?.node),
123
+ ),
124
+ );
125
+ return { job: null, diagnostics };
126
+ }
127
+
128
+ const tablesField = object.fields.get("dbTables");
129
+ let dbTables: string[] | null = null;
130
+ if (tablesField === undefined) {
131
+ note("dbTables is missing; declare the relations the job touches ([] means none)", undefined);
132
+ } else if (!tablesField.ok) {
133
+ note(
134
+ "dbTables cannot be read statically: " + (tablesField.reason ?? "unsupported"),
135
+ tablesField.node,
136
+ );
137
+ } else {
138
+ dbTables = stringArray(tablesField.value);
139
+ if (dbTables === null) note("dbTables must be a string[]", tablesField.node);
140
+ }
141
+
142
+ const rawSqlField = object.fields.get("rawSql");
143
+ let rawSql: boolean | null = false;
144
+ if (rawSqlField !== undefined && !rawSqlField.ok) {
145
+ rawSql = null;
146
+ note(
147
+ "rawSql cannot be read statically: " + (rawSqlField.reason ?? "unsupported"),
148
+ rawSqlField.node,
149
+ );
150
+ } else if (rawSqlField !== undefined) {
151
+ if (typeof rawSqlField.value === "boolean") rawSql = rawSqlField.value;
152
+ else {
153
+ // An unreadable derogation is never assumed: db.execute then fails closed (D-157).
154
+ rawSql = null;
155
+ note("rawSql must be a boolean", rawSqlField.node);
156
+ }
157
+ }
158
+
159
+ return {
160
+ job: { name, sourceFile: file.path, source: ref, dbTables, rawSql, unknown },
161
+ diagnostics,
162
+ };
163
+ }
164
+
165
+ /** Read every job module under `server/jobs/` into facts (test files are skipped). */
166
+ export function buildJobFacts(graph: ModuleGraph): JobResult {
167
+ const files = [...graph.files.values()]
168
+ .filter((file) => {
169
+ const relative = relativePath(graph.root, file.path);
170
+ return relative.startsWith(JOBS_DIR) && !relative.endsWith(".test.ts");
171
+ })
172
+ .toSorted((left, right) => left.path.localeCompare(right.path));
173
+ const jobs: JobFact[] = [];
174
+ const diagnostics: Diagnostic[] = [];
175
+ for (const file of files) {
176
+ const scanned = scanFile(graph, file);
177
+ if (scanned.job !== null) jobs.push(scanned.job);
178
+ diagnostics.push(...scanned.diagnostics);
179
+ }
180
+ return { jobs, diagnostics: sortDiagnostics(diagnostics) };
181
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Small ESTree/JSX helpers shared by the route-table and page-preview passes.
3
+ *
4
+ * The analyzer's parser contract is parser-independent ESTree, so these helpers only read
5
+ * structural keys (`openingElement.name`, `attributes`, `children`, `expression`); they never
6
+ * execute project code and never depend on oxc-specific node types.
7
+ */
8
+
9
+ import { asNode, nodeArray, type AstNode } from "../ast/node.ts";
10
+
11
+ /** `JSXIdentifier` / `JSXMemberExpression` / `JSXNamespacedName` -> display name. */
12
+ export function jsxName(node: AstNode | undefined): string | null {
13
+ if (node === undefined) return null;
14
+ if (node.type === "JSXIdentifier") return typeof node.name === "string" ? node.name : null;
15
+ if (node.type === "JSXMemberExpression") {
16
+ const object = jsxName(asNode(node.object));
17
+ const property = jsxName(asNode(node.property));
18
+ return object === null || property === null ? null : object + "." + property;
19
+ }
20
+ if (node.type === "JSXNamespacedName") {
21
+ const namespace = jsxName(asNode(node.namespace));
22
+ const name = jsxName(asNode(node.name));
23
+ return namespace === null || name === null ? null : namespace + ":" + name;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function openingElement(node: AstNode): AstNode | undefined {
29
+ return node.type === "JSXElement" ? asNode(node.openingElement) : undefined;
30
+ }
31
+
32
+ export function elementName(node: AstNode): string | null {
33
+ return jsxName(asNode(openingElement(node)?.name));
34
+ }
35
+
36
+ export function jsxChildren(node: AstNode): AstNode[] {
37
+ return nodeArray(node, "children");
38
+ }
39
+
40
+ export function jsxAttributes(node: AstNode): AstNode[] {
41
+ const opening = openingElement(node);
42
+ return opening === undefined ? [] : nodeArray(opening, "attributes");
43
+ }
44
+
45
+ /** The first attribute with this name, or undefined. */
46
+ export function jsxAttribute(node: AstNode, name: string): AstNode | undefined {
47
+ for (const attribute of jsxAttributes(node)) {
48
+ if (attribute.type !== "JSXAttribute") continue;
49
+ if (jsxName(asNode(attribute.name)) === name) return attribute;
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ /** The value expression of an attribute, unwrapped from `JSXExpressionContainer`. */
55
+ export function jsxAttributeValue(attribute: AstNode | undefined): AstNode | undefined {
56
+ if (attribute === undefined) return undefined;
57
+ const value = asNode(attribute.value);
58
+ if (value === undefined) return undefined;
59
+ if (value.type === "JSXExpressionContainer") return asNode(value.expression);
60
+ return value;
61
+ }
62
+
63
+ /** A JSX element/fragment value, when the expression is one. */
64
+ export function jsxElementValue(value: AstNode | undefined): AstNode | undefined {
65
+ if (value === undefined) return undefined;
66
+ if (value.type === "JSXElement" || value.type === "JSXFragment") return value;
67
+ return undefined;
68
+ }
69
+
70
+ export function isJsxElement(node: AstNode | undefined): node is AstNode {
71
+ return node !== undefined && (node.type === "JSXElement" || node.type === "JSXFragment");
72
+ }
73
+
74
+ /** JSX elements that carry a page section (`<div>`/`<span>` are flattened away). */
75
+ const SECTION_INTRINSICS = new Set([
76
+ "header",
77
+ "main",
78
+ "section",
79
+ "nav",
80
+ "aside",
81
+ "footer",
82
+ "form",
83
+ "table",
84
+ "ul",
85
+ "h1",
86
+ "h2",
87
+ "h3",
88
+ ]);
89
+
90
+ export function isSectionIntrinsic(name: string): boolean {
91
+ return SECTION_INTRINSICS.has(name);
92
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Client path expressions -> static prefixes and route matches (§3.4 of the AST DSL
3
+ * contract).
4
+ *
5
+ * A path is read as a static prefix plus a dynamic tail: literal strings and const strings
6
+ * are exact, while concatenations and templates keep the leading static segments. One hop of
7
+ * a path-builder function is inlined (the builder's return expression); anything deeper is
8
+ * dynamic, not guessed.
9
+ */
10
+
11
+ import {
12
+ asNode,
13
+ identifierName,
14
+ isAstNode,
15
+ nodeArray,
16
+ unwrapExpression,
17
+ type AstNode,
18
+ } from "../ast/node.ts";
19
+ import { defaultExportDeclaration, localInitializer } from "../module-info.ts";
20
+ import { evaluateStatic, type StaticContext } from "../static-eval.ts";
21
+ import type { HttpMethod, PathKind, RouteFact, UnknownFact } from "../types.ts";
22
+ import { routeFactId } from "./route-location.ts";
23
+ import { findDeclaration, refOf } from "./static-read.ts";
24
+
25
+ const MAX_RESOLVE_DEPTH = 8;
26
+ export interface ExtractedPath {
27
+ readonly kind: PathKind;
28
+ /** Source text of the argument, for the inspector. */
29
+ readonly expression: string;
30
+ /** Leading static text; null when the path has no readable static part. */
31
+ readonly staticPrefix: string | null;
32
+ /** Fully static path, when the whole expression is known. */
33
+ readonly path: string | null;
34
+ readonly unknown: readonly UnknownFact[];
35
+ }
36
+ interface PartialPrefix {
37
+ readonly prefix: string;
38
+ readonly complete: boolean;
39
+ }
40
+
41
+ function sourceText(source: string, node: AstNode | undefined): string {
42
+ if (node === undefined || typeof node.start !== "number" || typeof node.end !== "number") {
43
+ return "";
44
+ }
45
+ return source.slice(node.start, node.end).trim();
46
+ }
47
+
48
+ function cookedString(quasi: AstNode | undefined): string {
49
+ // TemplateElement.value is a plain { raw, cooked } record, not an AST node (asNode rejects it).
50
+ const value = quasi?.value;
51
+ if (typeof value !== "object" || value === null) return "";
52
+ const cooked = (value as Record<string, unknown>).cooked;
53
+ return typeof cooked === "string" ? cooked : "";
54
+ }
55
+
56
+ function evaluatePrimitive(node: AstNode, context: StaticContext): string | null {
57
+ const evaluated = evaluateStatic(node, context);
58
+ const value = evaluated.ok ? evaluated.value : undefined;
59
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
60
+ ? String(value)
61
+ : null;
62
+ }
63
+
64
+ /** Leading static text of a literal, template or `+` chain, plus whether it is whole. */
65
+ function partialStaticPrefix(
66
+ node: AstNode | undefined,
67
+ context: StaticContext,
68
+ depth = 0,
69
+ ): PartialPrefix | null {
70
+ if (node === undefined || depth > MAX_RESOLVE_DEPTH) return null;
71
+ const unwrapped = unwrapExpression(node);
72
+ if (unwrapped === undefined) return null;
73
+ if (unwrapped.type === "Literal") {
74
+ return typeof unwrapped.value === "string" ? { prefix: unwrapped.value, complete: true } : null;
75
+ }
76
+ if (unwrapped.type === "TemplateLiteral") {
77
+ const quasis = nodeArray(unwrapped, "quasis");
78
+ const expressions = nodeArray(unwrapped, "expressions");
79
+ let prefix = "";
80
+ for (const [index, quasi] of quasis.entries()) {
81
+ prefix += cookedString(quasi);
82
+ const expression = expressions[index];
83
+ if (expression === undefined) continue;
84
+ const value = evaluatePrimitive(expression, context);
85
+ if (value === null) return { prefix, complete: false };
86
+ prefix += value;
87
+ }
88
+ return { prefix, complete: true };
89
+ }
90
+ if (unwrapped.type === "BinaryExpression" && unwrapped.operator === "+") {
91
+ const left = partialStaticPrefix(asNode(unwrapped.left), context, depth + 1);
92
+ if (left === null) return null;
93
+ if (!left.complete) return left;
94
+ const right = partialStaticPrefix(asNode(unwrapped.right), context, depth + 1);
95
+ return right === null
96
+ ? { prefix: left.prefix, complete: false }
97
+ : { prefix: left.prefix + right.prefix, complete: right.complete };
98
+ }
99
+ const value = evaluatePrimitive(unwrapped, context);
100
+ return value === null ? null : { prefix: value, complete: true };
101
+ }
102
+
103
+ function firstReturn(node: AstNode): AstNode | undefined {
104
+ if (node.type === "ReturnStatement") return asNode(node.argument);
105
+ for (const key of Object.keys(node)) {
106
+ if (key === "type" || key === "start" || key === "end") continue;
107
+ const value = node[key];
108
+ const children = (Array.isArray(value) ? value : [value]).filter(isAstNode);
109
+ for (const child of children) {
110
+ // A nested function owns its return statements; only this function's count.
111
+ if (child.type.endsWith("FunctionExpression") || child.type === "FunctionDeclaration") {
112
+ continue;
113
+ }
114
+ const found = firstReturn(child);
115
+ if (found !== undefined) return found;
116
+ }
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function functionReturnExpression(fn: AstNode): AstNode | undefined {
122
+ const body = asNode(fn.body);
123
+ if (body === undefined) return undefined;
124
+ return body.type === "BlockStatement" ? firstReturn(body) : body;
125
+ }
126
+
127
+ /** Inline one hop of an imported path-builder function (its return expression). */
128
+ function resolvePathBuilder(call: AstNode, context: StaticContext): PartialPrefix | null {
129
+ const callee = unwrapExpression(call.callee);
130
+ const name = identifierName(callee);
131
+ const file = context.graph.file(context.file);
132
+ if (name === undefined || file === undefined) return null;
133
+ const imported = file.module.imports.find((entry) => entry.local === name);
134
+ if (imported === undefined) return null;
135
+ const target = context.graph.resolve(context.file, imported.specifier);
136
+ if (target === null || target.startsWith("node_modules/")) return null;
137
+ const binding = context.graph.resolveExport(target, imported.imported ?? "default");
138
+ if (binding === null) return null;
139
+ const module = context.graph.file(binding.file);
140
+ if (module === undefined) return null;
141
+ const initializer =
142
+ binding.name === "default"
143
+ ? defaultExportDeclaration(module.program)
144
+ : (localInitializer(module.program, binding.name) ??
145
+ findDeclaration(module.program, binding.name));
146
+ const fn = unwrapExpression(initializer);
147
+ if (fn === undefined || !/Function/.test(fn.type)) return null;
148
+ const returned = functionReturnExpression(fn);
149
+ if (returned === undefined) return null;
150
+ return partialStaticPrefix(returned, { graph: context.graph, file: module.path });
151
+ }
152
+
153
+ /** Extract the path of one client call; failures stay in the unknown channel. */
154
+ export function extractPathExpression(
155
+ argument: AstNode | undefined,
156
+ context: StaticContext,
157
+ call: AstNode,
158
+ code: string,
159
+ ): ExtractedPath {
160
+ const file = context.graph.file(context.file);
161
+ const source = file?.source ?? "";
162
+ const callRef = refOf(context, call);
163
+ const argumentNode = unwrapExpression(argument);
164
+ const expression = sourceText(source, argumentNode) || sourceText(source, call);
165
+ if (argumentNode === undefined) {
166
+ return {
167
+ kind: "dynamic",
168
+ expression,
169
+ staticPrefix: null,
170
+ path: null,
171
+ unknown: [{ code, reason: "the call has no path argument", source: callRef }],
172
+ };
173
+ }
174
+
175
+ const unknown: UnknownFact[] = [];
176
+ if (argumentNode.type === "CallExpression") {
177
+ const builder = resolvePathBuilder(argumentNode, context);
178
+ if (builder !== null) {
179
+ return {
180
+ kind: "builder",
181
+ expression,
182
+ staticPrefix: builder.prefix.length === 0 ? null : builder.prefix,
183
+ path: builder.complete ? builder.prefix : null,
184
+ unknown,
185
+ };
186
+ }
187
+ unknown.push({
188
+ code,
189
+ reason: "the path builder could not be resolved statically",
190
+ source: callRef,
191
+ });
192
+ return { kind: "unknown", expression, staticPrefix: null, path: null, unknown };
193
+ }
194
+
195
+ if (argumentNode.type === "TemplateLiteral" || argumentNode.type === "BinaryExpression") {
196
+ const partial = partialStaticPrefix(argumentNode, context);
197
+ if (partial !== null) {
198
+ return {
199
+ kind: argumentNode.type === "TemplateLiteral" ? "template" : "concat",
200
+ expression,
201
+ staticPrefix: partial.prefix.length === 0 ? null : partial.prefix,
202
+ path: partial.complete ? partial.prefix : null,
203
+ unknown,
204
+ };
205
+ }
206
+ }
207
+
208
+ const evaluated = evaluateStatic(argumentNode, context);
209
+ if (evaluated.ok && typeof evaluated.value === "string") {
210
+ return {
211
+ kind: "literal",
212
+ expression,
213
+ staticPrefix: evaluated.value.length === 0 ? null : evaluated.value,
214
+ path: evaluated.value,
215
+ unknown,
216
+ };
217
+ }
218
+ unknown.push({
219
+ code,
220
+ reason: evaluated.ok ? "the path is not a string" : evaluated.reason,
221
+ source: callRef,
222
+ });
223
+ return { kind: "dynamic", expression, staticPrefix: null, path: null, unknown };
224
+ }
225
+
226
+ function pathSegments(path: string): string[] {
227
+ const base = path.split("?")[0] ?? "";
228
+ return (base.split("#")[0] ?? "").split("/").filter(Boolean);
229
+ }
230
+
231
+ function isDynamicSegment(segment: string): boolean {
232
+ return segment.startsWith("{") && segment.endsWith("}");
233
+ }
234
+
235
+ function templateMatches(template: readonly string[], segments: readonly string[]): boolean {
236
+ let index = 0;
237
+ for (; index < template.length; index += 1) {
238
+ const templateSegment = template[index] ?? "";
239
+ if (templateSegment.startsWith("{*")) return segments.length > index;
240
+ if (isDynamicSegment(templateSegment)) {
241
+ if (index >= segments.length) return false;
242
+ continue;
243
+ }
244
+ if (segments[index] !== templateSegment) return false;
245
+ }
246
+ return segments.length === index;
247
+ }
248
+
249
+ function prefixMatches(
250
+ template: readonly string[],
251
+ prefixSegments: readonly string[],
252
+ trailingSlash: boolean,
253
+ ): boolean {
254
+ for (const [index, prefixSegment] of prefixSegments.entries()) {
255
+ const templateSegment = template[index];
256
+ if (templateSegment === undefined) return false;
257
+ if (templateSegment.startsWith("{*")) return false;
258
+ if (isDynamicSegment(templateSegment)) continue;
259
+ if (templateSegment !== prefixSegment) return false;
260
+ }
261
+ const rest = template.slice(prefixSegments.length);
262
+ // A trailing slash means at least one more (dynamic) segment at the call site.
263
+ if (trailingSlash && rest.length === 0) return false;
264
+ return rest.every((segment) => isDynamicSegment(segment));
265
+ }
266
+
267
+ function routeMatches(
268
+ routePath: string,
269
+ path: string | null,
270
+ staticPrefix: string | null,
271
+ ): boolean {
272
+ const template = pathSegments(routePath);
273
+ if (path !== null) return templateMatches(template, pathSegments(path));
274
+ if (staticPrefix === null) return false;
275
+ return prefixMatches(template, pathSegments(staticPrefix), staticPrefix.endsWith("/"));
276
+ }
277
+
278
+ /** Route ids whose template can produce the call path for the given method. */
279
+ export function matchRouteIds(
280
+ routes: readonly RouteFact[],
281
+ path: string | null,
282
+ staticPrefix: string | null,
283
+ method: HttpMethod,
284
+ ): string[] {
285
+ if (path === null && staticPrefix === null) return [];
286
+ const ids: string[] = [];
287
+ const seen = new Set<string>();
288
+ for (const route of routes) {
289
+ if (route.method !== "ALL" && route.method !== method) continue;
290
+ if (!routeMatches(route.path, path, staticPrefix)) continue;
291
+ const id = routeFactId(route);
292
+ if (seen.has(id)) continue;
293
+ seen.add(id);
294
+ ids.push(id);
295
+ }
296
+ return ids;
297
+ }