@ontrails/warden 0.2.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 (188) hide show
  1. package/CHANGELOG.md +1556 -0
  2. package/README.md +200 -0
  3. package/bin/warden.ts +51 -0
  4. package/package.json +51 -0
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +2159 -0
  7. package/src/command.ts +1167 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +260 -0
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +178 -0
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +295 -0
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/regrade-history.ts +188 -0
  18. package/src/regrade-receipt.ts +484 -0
  19. package/src/resolve.ts +531 -0
  20. package/src/rules/activation-orphan.ts +97 -0
  21. package/src/rules/captured-kernel.ts +375 -0
  22. package/src/rules/circular-refs.ts +150 -0
  23. package/src/rules/cli-command-route-coherence.ts +177 -0
  24. package/src/rules/composes-declarations.ts +839 -0
  25. package/src/rules/context-no-surface-types.ts +214 -0
  26. package/src/rules/dead-internal-trail.ts +161 -0
  27. package/src/rules/dead-public-trail.ts +258 -0
  28. package/src/rules/draft-file-marking.ts +155 -0
  29. package/src/rules/draft-visible-debt.ts +83 -0
  30. package/src/rules/duplicate-exported-symbol.ts +172 -0
  31. package/src/rules/duplicate-public-contract.ts +137 -0
  32. package/src/rules/entity-exists.ts +254 -0
  33. package/src/rules/entity-ids.ts +15 -0
  34. package/src/rules/error-mapping-completeness.ts +290 -0
  35. package/src/rules/example-valid.ts +395 -0
  36. package/src/rules/fires-declarations.ts +740 -0
  37. package/src/rules/governed-symbol-residue.ts +535 -0
  38. package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
  39. package/src/rules/implementation-returns-result.ts +1733 -0
  40. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  41. package/src/rules/incomplete-crud.ts +583 -0
  42. package/src/rules/index.ts +328 -0
  43. package/src/rules/intent-propagation.ts +125 -0
  44. package/src/rules/layer-field-name-drift.ts +102 -0
  45. package/src/rules/library-render-coherence.ts +97 -0
  46. package/src/rules/metadata.ts +929 -0
  47. package/src/rules/missing-reconcile.ts +97 -0
  48. package/src/rules/missing-visibility.ts +111 -0
  49. package/src/rules/no-destructured-compose.ts +196 -0
  50. package/src/rules/no-dev-permit-in-source.ts +99 -0
  51. package/src/rules/no-direct-implementation-call.ts +52 -0
  52. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  53. package/src/rules/no-legacy-layer-imports.ts +211 -0
  54. package/src/rules/no-native-error-result.ts +118 -0
  55. package/src/rules/no-redundant-result-error-wrap.ts +382 -0
  56. package/src/rules/no-retired-cross-vocabulary.ts +203 -0
  57. package/src/rules/no-sync-result-assumption.ts +1199 -0
  58. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  59. package/src/rules/no-throw-in-implementation.ts +48 -0
  60. package/src/rules/no-top-level-surface.ts +371 -0
  61. package/src/rules/on-references-exist.ts +194 -0
  62. package/src/rules/orphaned-signal.ts +149 -0
  63. package/src/rules/owner-render-parity.ts +143 -0
  64. package/src/rules/permit-governance.ts +25 -0
  65. package/src/rules/prefer-schema-inference.ts +141 -0
  66. package/src/rules/public-export-example-coverage.ts +561 -0
  67. package/src/rules/public-internal-deep-imports.ts +454 -0
  68. package/src/rules/public-output-schema.ts +29 -0
  69. package/src/rules/public-union-output-discriminants.ts +150 -0
  70. package/src/rules/read-intent-fires.ts +188 -0
  71. package/src/rules/reference-exists.ts +97 -0
  72. package/src/rules/registry-names.ts +171 -0
  73. package/src/rules/resolved-import-boundary.ts +146 -0
  74. package/src/rules/resource-declarations.ts +697 -0
  75. package/src/rules/resource-exists.ts +181 -0
  76. package/src/rules/resource-id-grammar.ts +65 -0
  77. package/src/rules/resource-mock-coverage.ts +115 -0
  78. package/src/rules/retired-vocabulary.ts +1407 -0
  79. package/src/rules/scan.ts +59 -0
  80. package/src/rules/scheduled-destroy-intent.ts +44 -0
  81. package/src/rules/signal-graph-coaching.ts +220 -0
  82. package/src/rules/source/composition.ts +165 -0
  83. package/src/rules/source/drafts.ts +164 -0
  84. package/src/rules/source/entities.ts +618 -0
  85. package/src/rules/source/pragmas.ts +45 -0
  86. package/src/rules/source/resources.ts +64 -0
  87. package/src/rules/source/signals.ts +397 -0
  88. package/src/rules/source/stores.ts +310 -0
  89. package/src/rules/specs.ts +388 -0
  90. package/src/rules/static-resource-accessor-preference.ts +654 -0
  91. package/src/rules/structure.ts +234 -0
  92. package/src/rules/surface-overlay-coherence.ts +262 -0
  93. package/src/rules/surface-trailhead-coherence.ts +366 -0
  94. package/src/rules/trail-fork-coaching.ts +625 -0
  95. package/src/rules/trail-versioning-source.ts +1076 -0
  96. package/src/rules/trail-versioning-topo.ts +172 -0
  97. package/src/rules/trailhead-override-divergence.ts +356 -0
  98. package/src/rules/types.ts +464 -0
  99. package/src/rules/unmaterialized-activation-source.ts +85 -0
  100. package/src/rules/unreachable-detour-shadowing.ts +343 -0
  101. package/src/rules/valid-describe-refs.ts +224 -0
  102. package/src/rules/valid-detour-contract.ts +78 -0
  103. package/src/rules/warden-export-symmetry.ts +540 -0
  104. package/src/rules/warden-rules-use-ast.ts +1109 -0
  105. package/src/rules/webhook-route-collision.ts +306 -0
  106. package/src/rules/workspace-lock-ownership.ts +34 -0
  107. package/src/trails/activation-orphan.trail.ts +84 -0
  108. package/src/trails/captured-kernel.trail.ts +108 -0
  109. package/src/trails/circular-refs.trail.ts +29 -0
  110. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  111. package/src/trails/composes-declarations.trail.ts +22 -0
  112. package/src/trails/context-no-surface-types.trail.ts +21 -0
  113. package/src/trails/dead-internal-trail.trail.ts +26 -0
  114. package/src/trails/dead-public-trail.trail.ts +31 -0
  115. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  116. package/src/trails/draft-file-marking.trail.ts +16 -0
  117. package/src/trails/draft-visible-debt.trail.ts +16 -0
  118. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  119. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  120. package/src/trails/entity-exists.trail.ts +21 -0
  121. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  122. package/src/trails/example-valid.trail.ts +25 -0
  123. package/src/trails/fires-declarations.trail.ts +23 -0
  124. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  125. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  126. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  127. package/src/trails/implementation-returns-result.trail.ts +20 -0
  128. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  129. package/src/trails/incomplete-crud.trail.ts +39 -0
  130. package/src/trails/index.ts +91 -0
  131. package/src/trails/intent-propagation.trail.ts +30 -0
  132. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  133. package/src/trails/library-render-coherence.trail.ts +43 -0
  134. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  135. package/src/trails/missing-reconcile.trail.ts +33 -0
  136. package/src/trails/missing-visibility.trail.ts +22 -0
  137. package/src/trails/no-destructured-compose.trail.ts +44 -0
  138. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  139. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  140. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  141. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  142. package/src/trails/no-native-error-result.trail.ts +18 -0
  143. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  144. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  145. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  146. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  147. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  148. package/src/trails/no-top-level-surface.trail.ts +43 -0
  149. package/src/trails/on-references-exist.trail.ts +21 -0
  150. package/src/trails/orphaned-signal.trail.ts +36 -0
  151. package/src/trails/owner-render-parity.trail.ts +26 -0
  152. package/src/trails/pending-force.trail.ts +21 -0
  153. package/src/trails/permit-governance.trail.ts +51 -0
  154. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  155. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  156. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  157. package/src/trails/public-output-schema.trail.ts +55 -0
  158. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  159. package/src/trails/read-intent-fires.trail.ts +20 -0
  160. package/src/trails/reference-exists.trail.ts +25 -0
  161. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  162. package/src/trails/resource-declarations.trail.ts +25 -0
  163. package/src/trails/resource-exists.trail.ts +27 -0
  164. package/src/trails/resource-id-grammar.trail.ts +39 -0
  165. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  166. package/src/trails/run.ts +205 -0
  167. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  168. package/src/trails/schema.ts +295 -0
  169. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  170. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  171. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  172. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  173. package/src/trails/topo.ts +6 -0
  174. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  175. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  176. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  177. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  178. package/src/trails/valid-describe-refs.trail.ts +18 -0
  179. package/src/trails/valid-detour-contract.trail.ts +71 -0
  180. package/src/trails/version-gap.trail.ts +35 -0
  181. package/src/trails/version-pinned-compose.trail.ts +23 -0
  182. package/src/trails/version-without-examples.trail.ts +38 -0
  183. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  184. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  185. package/src/trails/webhook-route-collision.trail.ts +50 -0
  186. package/src/trails/workspace-lock-ownership.trail.ts +16 -0
  187. package/src/trails/wrap-rule.ts +269 -0
  188. package/src/workspaces.ts +264 -0
@@ -0,0 +1,310 @@
1
+ /** Warden-private store/factory pattern helpers. */
2
+
3
+ import {
4
+ extractBindingName,
5
+ findConfigProperty,
6
+ getPropertyName,
7
+ identifierName,
8
+ walk,
9
+ } from '@ontrails/source';
10
+ import type { AstNode } from '@ontrails/source';
11
+
12
+ export interface StoreTableDefinition {
13
+ /** Table name declared inside store({ ... }). */
14
+ readonly name: string;
15
+ /**
16
+ * Local binding name of the enclosing `store(...)` declaration, if the
17
+ * `store(...)` call is bound to a `const`/`let`/`var` (e.g. `db` in
18
+ * `const db = store({ ... })`). Null for anonymous stores.
19
+ */
20
+ readonly storeBinding: string | null;
21
+ /**
22
+ * Stable composite key for this table in the form `${storeBinding}:${name}`,
23
+ * falling back to the bare `name` when the store is anonymous. Use this for
24
+ * compose-rule / compose-file keying so two stores with the same table name
25
+ * never collide.
26
+ */
27
+ readonly key: string;
28
+ /** Start offset of the table property declaration. */
29
+ readonly start: number;
30
+ /** Whether the authored table opts into version tracking. */
31
+ readonly versioned: boolean;
32
+ }
33
+
34
+ /**
35
+ * Build a composite key for a store table: `${storeBinding}:${tableName}`,
36
+ * falling back to the bare `tableName` when the enclosing store has no local
37
+ * binding. Centralized so rule keying stays stable.
38
+ *
39
+ * @remarks
40
+ * The key is intentionally file-local (no module path prefix). Compose-file
41
+ * aggregation in `ProjectContext` merges keys from all files, so two files
42
+ * with `const db = store({ notes: ... })` both produce `db:notes` — this is
43
+ * the desired behavior because the warden checks for *pattern completeness*
44
+ * across the project and matching keys signals that the same logical table
45
+ * is covered. If two genuinely different tables share a binding and name,
46
+ * that is a code-level naming collision the developer should resolve.
47
+ */
48
+ export const makeStoreTableKey = (
49
+ storeBinding: string | null,
50
+ tableName: string
51
+ ): string => (storeBinding ? `${storeBinding}:${tableName}` : tableName);
52
+
53
+ const isBooleanLiteral = (node: AstNode | undefined): boolean =>
54
+ Boolean(
55
+ node &&
56
+ ((node.type === 'BooleanLiteral' &&
57
+ (node as unknown as { value?: unknown }).value === true) ||
58
+ (node.type === 'Literal' &&
59
+ (node as unknown as { value?: unknown }).value === true))
60
+ );
61
+
62
+ /**
63
+ * Check if a node is a `CallExpression` to the identifier `name`.
64
+ *
65
+ * e.g. `isNamedCall(node, 'store')` matches `store({...})` but not
66
+ * `someObj.store()` or `storeAlt()`.
67
+ */
68
+ export const isNamedCall = (node: AstNode | undefined, name: string): boolean =>
69
+ !!node &&
70
+ node.type === 'CallExpression' &&
71
+ identifierName((node as unknown as { callee?: AstNode }).callee) === name;
72
+
73
+ /**
74
+ * Narrow a member-expression node (`a.b` or `a['b']`) to its `object` /
75
+ * `property` pair, returning `null` for anything else.
76
+ */
77
+ export const getMemberExpression = (
78
+ node: AstNode | undefined
79
+ ): { readonly object?: AstNode; readonly property?: AstNode } | null => {
80
+ if (
81
+ !node ||
82
+ (node.type !== 'MemberExpression' && node.type !== 'StaticMemberExpression')
83
+ ) {
84
+ return null;
85
+ }
86
+
87
+ return node as unknown as {
88
+ readonly object?: AstNode;
89
+ readonly property?: AstNode;
90
+ };
91
+ };
92
+
93
+ /**
94
+ * Resolve a `<store>.tables.<name>` member expression to its store binding
95
+ * and table name.
96
+ *
97
+ * Returns `null` for anything that isn't a two-level member access ending in
98
+ * `.tables.<name>`. The store binding is the identifier of the object owning
99
+ * `.tables` — typically the local binding from `const db = store(...)`.
100
+ */
101
+ export const extractStoreTableFromMember = (
102
+ node: AstNode | undefined
103
+ ): {
104
+ readonly storeBinding: string | null;
105
+ readonly tableName: string;
106
+ } | null => {
107
+ const member = getMemberExpression(node);
108
+ const tableName = member ? getPropertyName(member.property) : null;
109
+ const tablesMember = member ? getMemberExpression(member.object) : null;
110
+ if (!tableName || !tablesMember) {
111
+ return null;
112
+ }
113
+
114
+ if (getPropertyName(tablesMember.property) !== 'tables') {
115
+ return null;
116
+ }
117
+
118
+ const storeBinding = identifierName(tablesMember.object) ?? null;
119
+ return { storeBinding, tableName };
120
+ };
121
+
122
+ /**
123
+ * Collect `const foo = <store>.tables.<name>` bindings from a parsed file,
124
+ * keyed by the local binding name. Values are the composite table key
125
+ * (`${storeBinding}:${tableName}`) so callers can dedupe across stores that
126
+ * share a table name.
127
+ */
128
+ export const collectNamedStoreTableIds = (
129
+ ast: AstNode
130
+ ): ReadonlyMap<string, string> => {
131
+ const ids = new Map<string, string>();
132
+
133
+ walk(ast, (node) => {
134
+ if (node.type !== 'VariableDeclarator') {
135
+ return;
136
+ }
137
+
138
+ const { id, init } = node as unknown as {
139
+ readonly id?: AstNode;
140
+ readonly init?: AstNode;
141
+ };
142
+ const name = extractBindingName(id);
143
+ const table = extractStoreTableFromMember(init);
144
+ if (name && table) {
145
+ ids.set(name, makeStoreTableKey(table.storeBinding, table.tableName));
146
+ }
147
+ });
148
+
149
+ return ids;
150
+ };
151
+
152
+ /**
153
+ * Resolve an argument node to a composite store-table key
154
+ * (`${storeBinding}:${tableName}` or bare `tableName` when anonymous).
155
+ *
156
+ * Handles the two authoring patterns:
157
+ * - direct member access: `db.tables.notes`
158
+ * - identifier reference: `const notesTable = db.tables.notes; crud(notesTable, …)`
159
+ */
160
+ export const deriveStoreTableId = (
161
+ node: AstNode | undefined,
162
+ namedStoreTableIds: ReadonlyMap<string, string>
163
+ ): string | null => {
164
+ if (!node) {
165
+ return null;
166
+ }
167
+
168
+ if (node.type === 'Identifier') {
169
+ const name = identifierName(node);
170
+ return name ? (namedStoreTableIds.get(name) ?? null) : null;
171
+ }
172
+
173
+ const member = extractStoreTableFromMember(node);
174
+ return member
175
+ ? makeStoreTableKey(member.storeBinding, member.tableName)
176
+ : null;
177
+ };
178
+
179
+ const extractStoreTableDefinitions = (
180
+ node: AstNode,
181
+ storeBinding: string | null
182
+ ): readonly StoreTableDefinition[] => {
183
+ if (!isNamedCall(node, 'store')) {
184
+ return [];
185
+ }
186
+
187
+ const [tablesArg] = ((node as unknown as { arguments?: readonly AstNode[] })
188
+ .arguments ?? []) as readonly AstNode[];
189
+ if (!tablesArg || tablesArg.type !== 'ObjectExpression') {
190
+ return [];
191
+ }
192
+
193
+ const properties = tablesArg['properties'] as readonly AstNode[] | undefined;
194
+ if (!properties) {
195
+ return [];
196
+ }
197
+
198
+ return properties.flatMap((property) => {
199
+ if (property.type !== 'Property') {
200
+ return [];
201
+ }
202
+
203
+ const name = getPropertyName(property.key);
204
+ const value = property.value as AstNode | undefined;
205
+ if (!name || value?.type !== 'ObjectExpression') {
206
+ return [];
207
+ }
208
+
209
+ const versionedProp = findConfigProperty(value, 'versioned');
210
+ return [
211
+ {
212
+ key: makeStoreTableKey(storeBinding, name),
213
+ name,
214
+ start: property.start,
215
+ storeBinding,
216
+ versioned: isBooleanLiteral(
217
+ versionedProp?.value as AstNode | undefined
218
+ ),
219
+ },
220
+ ];
221
+ });
222
+ };
223
+
224
+ export const findStoreTableDefinitions = (
225
+ ast: AstNode
226
+ ): readonly StoreTableDefinition[] => {
227
+ const definitions: StoreTableDefinition[] = [];
228
+ const seenStoreCalls = new WeakSet<AstNode>();
229
+
230
+ // First pass: bound stores (walk VariableDeclarators so we know the binding).
231
+ walk(ast, (node) => {
232
+ if (node.type !== 'VariableDeclarator') {
233
+ return;
234
+ }
235
+
236
+ const { id, init } = node as unknown as {
237
+ readonly id?: AstNode;
238
+ readonly init?: AstNode;
239
+ };
240
+ if (!init || !isNamedCall(init, 'store')) {
241
+ return;
242
+ }
243
+
244
+ seenStoreCalls.add(init);
245
+ const storeBinding = extractBindingName(id);
246
+ definitions.push(...extractStoreTableDefinitions(init, storeBinding));
247
+ });
248
+
249
+ // Second pass: anonymous `store({...})` calls not bound to a variable
250
+ // (e.g. an inline default export). Use the bare table name as the key.
251
+ walk(ast, (node) => {
252
+ if (!isNamedCall(node, 'store') || seenStoreCalls.has(node)) {
253
+ return;
254
+ }
255
+ definitions.push(...extractStoreTableDefinitions(node, null));
256
+ });
257
+
258
+ return definitions;
259
+ };
260
+
261
+ export const collectCrudTableIds = (ast: AstNode): ReadonlySet<string> => {
262
+ const ids = new Set<string>();
263
+ const namedStoreTableIds = collectNamedStoreTableIds(ast);
264
+
265
+ walk(ast, (node) => {
266
+ if (!isNamedCall(node, 'crud')) {
267
+ return;
268
+ }
269
+
270
+ const [tableArg] = ((node as unknown as { arguments?: readonly AstNode[] })
271
+ .arguments ?? []) as readonly AstNode[];
272
+ const tableId = deriveStoreTableId(tableArg, namedStoreTableIds);
273
+ if (tableId) {
274
+ ids.add(tableId);
275
+ }
276
+ });
277
+
278
+ return ids;
279
+ };
280
+
281
+ export const collectReconcileTableIds = (ast: AstNode): ReadonlySet<string> => {
282
+ const ids = new Set<string>();
283
+ const namedStoreTableIds = collectNamedStoreTableIds(ast);
284
+
285
+ walk(ast, (node) => {
286
+ if (!isNamedCall(node, 'reconcile')) {
287
+ return;
288
+ }
289
+
290
+ const [configArg] = ((
291
+ node as unknown as {
292
+ arguments?: readonly AstNode[];
293
+ }
294
+ ).arguments ?? []) as readonly AstNode[];
295
+ if (!configArg || configArg.type !== 'ObjectExpression') {
296
+ return;
297
+ }
298
+
299
+ const tableProp = findConfigProperty(configArg, 'table');
300
+ const tableId = deriveStoreTableId(
301
+ tableProp?.value as AstNode | undefined,
302
+ namedStoreTableIds
303
+ );
304
+ if (tableId) {
305
+ ids.add(tableId);
306
+ }
307
+ });
308
+
309
+ return ids;
310
+ };
@@ -0,0 +1,388 @@
1
+ import {
2
+ captureBalanced,
3
+ lineNumberAt,
4
+ splitTopLevelEntriesWithOffsets,
5
+ } from './structure.js';
6
+ import type { SplitEntry } from './structure.js';
7
+
8
+ export interface ParsedEntry {
9
+ readonly line: number;
10
+ readonly start: number;
11
+ readonly text: string;
12
+ }
13
+
14
+ export interface ObjectProperty extends ParsedEntry {
15
+ readonly key: string;
16
+ readonly value: string;
17
+ }
18
+
19
+ export interface TrailLikeSpec {
20
+ readonly id: string;
21
+ readonly kind: 'signal' | 'trail';
22
+ readonly line: number;
23
+ readonly properties: ReadonlyMap<string, ObjectProperty>;
24
+ readonly specText: string;
25
+ readonly start: number;
26
+ }
27
+
28
+ export interface SchemaFieldInfo {
29
+ readonly derivedLabel: string;
30
+ readonly options?: readonly string[] | undefined;
31
+ readonly required: boolean;
32
+ }
33
+
34
+ // Match `trail(...)` / `signal(...)` declaration sites, not method calls.
35
+ // The negative lookbehind excludes `foo.signal(...)`, `foo?.signal(...)`, and
36
+ // similar member-expression call sites (including optional chaining with
37
+ // whitespace) where `signal` / `trail` is a property name, not a factory.
38
+ const TRAIL_LIKE_PATTERN = /(?<![.?])\b(trail|signal)\s*\(/g;
39
+
40
+ const PROPERTY_PATTERN =
41
+ /^(?:readonly\s+)?(?:(["'`])([^"'`]+)\1|([A-Za-z_$][\w$]*))\s*:\s*([\s\S]+)$/;
42
+
43
+ const OPTIONALISH_PATTERN =
44
+ /(?:^|[^\w])z\.(?:default|nullish|optional)\s*\(|\.(?:default|nullish|optional)\s*\(/;
45
+
46
+ const humanize = (str: string): string =>
47
+ str
48
+ .replaceAll(/([a-z])([A-Z])/g, '$1 $2')
49
+ .replaceAll(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
50
+ .replace(/^./, (ch) => ch.toUpperCase());
51
+
52
+ const trimWrapped = (
53
+ text: string,
54
+ open: '{' | '[',
55
+ close: '}' | ']'
56
+ ): string | null => {
57
+ const trimmed = text.trim();
58
+ if (!trimmed.startsWith(open) || !trimmed.endsWith(close)) {
59
+ return null;
60
+ }
61
+
62
+ return trimmed.slice(1, -1);
63
+ };
64
+
65
+ const firstEntry = (text: string): SplitEntry | null => {
66
+ const [entry] = splitTopLevelEntriesWithOffsets(text);
67
+ return entry ?? null;
68
+ };
69
+
70
+ const createParsedEntry = (
71
+ sourceCode: string,
72
+ start: number,
73
+ text: string
74
+ ): ParsedEntry => ({
75
+ line: lineNumberAt(sourceCode, start),
76
+ start,
77
+ text,
78
+ });
79
+
80
+ const createObjectProperty = (
81
+ entry: SplitEntry,
82
+ key: string,
83
+ value: string,
84
+ objectStart: number,
85
+ objectOffset: number,
86
+ sourceCode: string
87
+ ): ObjectProperty => {
88
+ const start = objectStart + objectOffset + 1 + entry.start;
89
+ return {
90
+ key,
91
+ line: lineNumberAt(sourceCode, start),
92
+ start,
93
+ text: entry.text,
94
+ value,
95
+ };
96
+ };
97
+
98
+ const parsePropertyEntry = (
99
+ entry: SplitEntry,
100
+ objectStart: number,
101
+ objectOffset: number,
102
+ sourceCode: string
103
+ ): ObjectProperty | null => {
104
+ const propertyMatch = entry.text.match(PROPERTY_PATTERN);
105
+ const key = propertyMatch?.[2] ?? propertyMatch?.[3];
106
+ const value = propertyMatch?.[4]?.trim();
107
+ if (!key || !value) {
108
+ return null;
109
+ }
110
+
111
+ return createObjectProperty(
112
+ entry,
113
+ key,
114
+ value,
115
+ objectStart,
116
+ objectOffset,
117
+ sourceCode
118
+ );
119
+ };
120
+
121
+ const isDefined = <T>(value: T | null): value is T => value !== null;
122
+
123
+ export const parseStringLiteral = (text: string): string | null => {
124
+ const trimmed = text.trim();
125
+ const [quote = ''] = trimmed;
126
+ const closer = quote === "'" || quote === '"' || quote === '`' ? quote : null;
127
+ if (closer === null || trimmed.at(-1) !== closer) {
128
+ return null;
129
+ }
130
+
131
+ const value = trimmed.slice(1, -1);
132
+ if (quote === '`' && value.includes('${')) {
133
+ return null;
134
+ }
135
+
136
+ return value;
137
+ };
138
+
139
+ export const parseArrayEntries = (
140
+ arrayText: string,
141
+ arrayStart: number,
142
+ sourceCode: string
143
+ ): readonly ParsedEntry[] => {
144
+ const inner = trimWrapped(arrayText, '[', ']');
145
+ if (inner === null) {
146
+ return [];
147
+ }
148
+
149
+ const arrayOffset = arrayText.indexOf('[');
150
+ return splitTopLevelEntriesWithOffsets(inner).map((entry) =>
151
+ createParsedEntry(
152
+ sourceCode,
153
+ arrayStart + arrayOffset + 1 + entry.start,
154
+ entry.text
155
+ )
156
+ );
157
+ };
158
+
159
+ export const parseObjectProperties = (
160
+ objectText: string,
161
+ objectStart: number,
162
+ sourceCode: string
163
+ ): ReadonlyMap<string, ObjectProperty> => {
164
+ const inner = trimWrapped(objectText, '{', '}');
165
+ if (inner === null) {
166
+ return new Map();
167
+ }
168
+
169
+ const objectOffset = objectText.indexOf('{');
170
+ const properties = splitTopLevelEntriesWithOffsets(inner)
171
+ .map((entry) =>
172
+ parsePropertyEntry(entry, objectStart, objectOffset, sourceCode)
173
+ )
174
+ .filter(isDefined);
175
+
176
+ return new Map(properties.map((property) => [property.key, property]));
177
+ };
178
+
179
+ const resolveSpecTarget = (
180
+ args: readonly SplitEntry[]
181
+ ): { id: string | null; specArg: SplitEntry } | null => {
182
+ const [firstArg, secondArg] = args;
183
+ if (!firstArg) {
184
+ return null;
185
+ }
186
+
187
+ const id = parseStringLiteral(firstArg.text);
188
+ return {
189
+ id,
190
+ specArg: id === null ? firstArg : (secondArg ?? firstArg),
191
+ };
192
+ };
193
+
194
+ const parseCallArguments = (
195
+ sourceCode: string,
196
+ callStart: number
197
+ ): { argsStart: number; argsText: string } | null => {
198
+ const openParen = sourceCode.indexOf('(', callStart);
199
+ const call = openParen === -1 ? null : captureBalanced(sourceCode, openParen);
200
+ if (call === null || openParen === -1) {
201
+ return null;
202
+ }
203
+
204
+ return {
205
+ argsStart: openParen + 1,
206
+ argsText: call.text.slice(1, -1),
207
+ };
208
+ };
209
+
210
+ const isSpecObject = (entry: SplitEntry | undefined): entry is SplitEntry =>
211
+ entry !== undefined && entry.text.trim().startsWith('{');
212
+
213
+ const resolveSpecId = (
214
+ resolved: { id: string | null; specArg: SplitEntry },
215
+ properties: ReadonlyMap<string, ObjectProperty>
216
+ ): string | null =>
217
+ resolved.id ?? parseStringLiteral(properties.get('id')?.value ?? '');
218
+
219
+ const buildTrailLikeSpec = (
220
+ sourceCode: string,
221
+ kind: 'signal' | 'trail',
222
+ specArg: SplitEntry,
223
+ specStart: number,
224
+ id: string,
225
+ properties: ReadonlyMap<string, ObjectProperty>
226
+ ): TrailLikeSpec => ({
227
+ id,
228
+ kind,
229
+ line: lineNumberAt(sourceCode, specStart),
230
+ properties,
231
+ specText: specArg.text,
232
+ start: specStart,
233
+ });
234
+
235
+ const parseResolvedSpec = (
236
+ sourceCode: string,
237
+ resolved: { id: string | null; specArg: SplitEntry },
238
+ call: { argsStart: number; argsText: string }
239
+ ): {
240
+ id: string;
241
+ properties: ReadonlyMap<string, ObjectProperty>;
242
+ specStart: number;
243
+ } | null => {
244
+ const specStart = call.argsStart + resolved.specArg.start;
245
+ const properties = parseObjectProperties(
246
+ resolved.specArg.text,
247
+ specStart,
248
+ sourceCode
249
+ );
250
+ const id = resolveSpecId(resolved, properties);
251
+ return id === null ? null : { id, properties, specStart };
252
+ };
253
+
254
+ const resolveTrailLikeSpec = (
255
+ sourceCode: string,
256
+ callStart: number
257
+ ): {
258
+ parsed: {
259
+ id: string;
260
+ properties: ReadonlyMap<string, ObjectProperty>;
261
+ specStart: number;
262
+ };
263
+ resolved: { id: string | null; specArg: SplitEntry };
264
+ } | null => {
265
+ const call = parseCallArguments(sourceCode, callStart);
266
+ if (call === null) {
267
+ return null;
268
+ }
269
+
270
+ const resolved = resolveSpecTarget(
271
+ splitTopLevelEntriesWithOffsets(call.argsText)
272
+ );
273
+ if (!resolved || !isSpecObject(resolved.specArg)) {
274
+ return null;
275
+ }
276
+
277
+ const parsed = parseResolvedSpec(sourceCode, resolved, call);
278
+ return parsed === null ? null : { parsed, resolved };
279
+ };
280
+
281
+ const parseTrailLikeMatch = (
282
+ sourceCode: string,
283
+ kind: 'signal' | 'trail',
284
+ callStart: number
285
+ ): TrailLikeSpec | null => {
286
+ const resolved = resolveTrailLikeSpec(sourceCode, callStart);
287
+ if (resolved === null) {
288
+ return null;
289
+ }
290
+
291
+ return buildTrailLikeSpec(
292
+ sourceCode,
293
+ kind,
294
+ resolved.resolved.specArg,
295
+ resolved.parsed.specStart,
296
+ resolved.parsed.id,
297
+ resolved.parsed.properties
298
+ );
299
+ };
300
+
301
+ const findCallArguments = (
302
+ sourceText: string,
303
+ pattern: RegExp
304
+ ): string | null => {
305
+ const index = pattern.exec(sourceText)?.index;
306
+ if (index === undefined) {
307
+ return null;
308
+ }
309
+
310
+ return parseCallArguments(sourceText, index)?.argsText ?? null;
311
+ };
312
+
313
+ const parseStringArrayLiteral = (
314
+ arrayText: string
315
+ ): readonly string[] | null => {
316
+ const inner = trimWrapped(arrayText, '[', ']');
317
+ if (inner === null) {
318
+ return null;
319
+ }
320
+
321
+ const values = splitTopLevelEntriesWithOffsets(inner).map((entry) =>
322
+ parseStringLiteral(entry.text)
323
+ );
324
+ const strings = values.filter((value): value is string => value !== null);
325
+ return strings.length === values.length ? strings : null;
326
+ };
327
+
328
+ const parseDescribeLabel = (fieldText: string): string | undefined => {
329
+ const args = findCallArguments(fieldText, /\.describe\s*\(/);
330
+ return args === null ? undefined : (parseStringLiteral(args) ?? undefined);
331
+ };
332
+
333
+ const parseEnumValues = (fieldText: string): readonly string[] | undefined => {
334
+ const args = findCallArguments(fieldText, /\bz\.enum\s*\(/);
335
+ const entry = args === null ? null : firstEntry(args);
336
+ return entry ? (parseStringArrayLiteral(entry.text) ?? undefined) : undefined;
337
+ };
338
+
339
+ const toSchemaFieldInfo = (
340
+ key: string,
341
+ property: ObjectProperty
342
+ ): SchemaFieldInfo => ({
343
+ derivedLabel: parseDescribeLabel(property.value) ?? humanize(key),
344
+ options: parseEnumValues(property.value),
345
+ required: !OPTIONALISH_PATTERN.test(property.value),
346
+ });
347
+
348
+ export const findTrailLikeSpecs = (
349
+ sourceCode: string
350
+ ): readonly TrailLikeSpec[] => {
351
+ const specs: TrailLikeSpec[] = [];
352
+
353
+ for (const match of sourceCode.matchAll(TRAIL_LIKE_PATTERN)) {
354
+ const callStart = match.index;
355
+ if (callStart === undefined) {
356
+ continue;
357
+ }
358
+
359
+ const kind = match[1] === 'signal' ? 'signal' : 'trail';
360
+ const spec = parseTrailLikeMatch(sourceCode, kind, callStart);
361
+ if (spec !== null) {
362
+ specs.push(spec);
363
+ }
364
+ }
365
+
366
+ return specs;
367
+ };
368
+
369
+ export const collectTrailIds = (sourceCode: string): ReadonlySet<string> =>
370
+ new Set(findTrailLikeSpecs(sourceCode).map((spec) => spec.id));
371
+
372
+ export const parseZodObjectShape = (
373
+ schemaText: string
374
+ ): ReadonlyMap<string, SchemaFieldInfo> => {
375
+ const args = findCallArguments(schemaText, /\bz\.object\s*\(/);
376
+ const shapeEntry = args === null ? null : firstEntry(args);
377
+ if (!shapeEntry || !shapeEntry.text.trim().startsWith('{')) {
378
+ return new Map();
379
+ }
380
+
381
+ const shape = parseObjectProperties(shapeEntry.text, 0, shapeEntry.text);
382
+ return new Map(
383
+ [...shape.entries()].map(([key, property]) => [
384
+ key,
385
+ toSchemaFieldInfo(key, property),
386
+ ])
387
+ );
388
+ };