@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.
- package/CHANGELOG.md +1556 -0
- package/README.md +200 -0
- package/bin/warden.ts +51 -0
- package/package.json +51 -0
- package/src/adapter-check.ts +136 -0
- package/src/cli.ts +2159 -0
- package/src/command.ts +1167 -0
- package/src/config.ts +193 -0
- package/src/draft.ts +22 -0
- package/src/drift.ts +260 -0
- package/src/fix.ts +126 -0
- package/src/formatters.ts +178 -0
- package/src/guide.ts +245 -0
- package/src/index.ts +295 -0
- package/src/project-context.ts +446 -0
- package/src/project-rules.ts +290 -0
- package/src/regrade-history.ts +188 -0
- package/src/regrade-receipt.ts +484 -0
- package/src/resolve.ts +531 -0
- package/src/rules/activation-orphan.ts +97 -0
- package/src/rules/captured-kernel.ts +375 -0
- package/src/rules/circular-refs.ts +150 -0
- package/src/rules/cli-command-route-coherence.ts +177 -0
- package/src/rules/composes-declarations.ts +839 -0
- package/src/rules/context-no-surface-types.ts +214 -0
- package/src/rules/dead-internal-trail.ts +161 -0
- package/src/rules/dead-public-trail.ts +258 -0
- package/src/rules/draft-file-marking.ts +155 -0
- package/src/rules/draft-visible-debt.ts +83 -0
- package/src/rules/duplicate-exported-symbol.ts +172 -0
- package/src/rules/duplicate-public-contract.ts +137 -0
- package/src/rules/entity-exists.ts +254 -0
- package/src/rules/entity-ids.ts +15 -0
- package/src/rules/error-mapping-completeness.ts +290 -0
- package/src/rules/example-valid.ts +395 -0
- package/src/rules/fires-declarations.ts +740 -0
- package/src/rules/governed-symbol-residue.ts +535 -0
- package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
- package/src/rules/implementation-returns-result.ts +1733 -0
- package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
- package/src/rules/incomplete-crud.ts +583 -0
- package/src/rules/index.ts +328 -0
- package/src/rules/intent-propagation.ts +125 -0
- package/src/rules/layer-field-name-drift.ts +102 -0
- package/src/rules/library-render-coherence.ts +97 -0
- package/src/rules/metadata.ts +929 -0
- package/src/rules/missing-reconcile.ts +97 -0
- package/src/rules/missing-visibility.ts +111 -0
- package/src/rules/no-destructured-compose.ts +196 -0
- package/src/rules/no-dev-permit-in-source.ts +99 -0
- package/src/rules/no-direct-implementation-call.ts +52 -0
- package/src/rules/no-legacy-cli-alias-export.ts +247 -0
- package/src/rules/no-legacy-layer-imports.ts +211 -0
- package/src/rules/no-native-error-result.ts +118 -0
- package/src/rules/no-redundant-result-error-wrap.ts +382 -0
- package/src/rules/no-retired-cross-vocabulary.ts +203 -0
- package/src/rules/no-sync-result-assumption.ts +1199 -0
- package/src/rules/no-throw-in-detour-recover.ts +225 -0
- package/src/rules/no-throw-in-implementation.ts +48 -0
- package/src/rules/no-top-level-surface.ts +371 -0
- package/src/rules/on-references-exist.ts +194 -0
- package/src/rules/orphaned-signal.ts +149 -0
- package/src/rules/owner-render-parity.ts +143 -0
- package/src/rules/permit-governance.ts +25 -0
- package/src/rules/prefer-schema-inference.ts +141 -0
- package/src/rules/public-export-example-coverage.ts +561 -0
- package/src/rules/public-internal-deep-imports.ts +454 -0
- package/src/rules/public-output-schema.ts +29 -0
- package/src/rules/public-union-output-discriminants.ts +150 -0
- package/src/rules/read-intent-fires.ts +188 -0
- package/src/rules/reference-exists.ts +97 -0
- package/src/rules/registry-names.ts +171 -0
- package/src/rules/resolved-import-boundary.ts +146 -0
- package/src/rules/resource-declarations.ts +697 -0
- package/src/rules/resource-exists.ts +181 -0
- package/src/rules/resource-id-grammar.ts +65 -0
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/retired-vocabulary.ts +1407 -0
- package/src/rules/scan.ts +59 -0
- package/src/rules/scheduled-destroy-intent.ts +44 -0
- package/src/rules/signal-graph-coaching.ts +220 -0
- package/src/rules/source/composition.ts +165 -0
- package/src/rules/source/drafts.ts +164 -0
- package/src/rules/source/entities.ts +618 -0
- package/src/rules/source/pragmas.ts +45 -0
- package/src/rules/source/resources.ts +64 -0
- package/src/rules/source/signals.ts +397 -0
- package/src/rules/source/stores.ts +310 -0
- package/src/rules/specs.ts +388 -0
- package/src/rules/static-resource-accessor-preference.ts +654 -0
- package/src/rules/structure.ts +234 -0
- package/src/rules/surface-overlay-coherence.ts +262 -0
- package/src/rules/surface-trailhead-coherence.ts +366 -0
- package/src/rules/trail-fork-coaching.ts +625 -0
- package/src/rules/trail-versioning-source.ts +1076 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/trailhead-override-divergence.ts +356 -0
- package/src/rules/types.ts +464 -0
- package/src/rules/unmaterialized-activation-source.ts +85 -0
- package/src/rules/unreachable-detour-shadowing.ts +343 -0
- package/src/rules/valid-describe-refs.ts +224 -0
- package/src/rules/valid-detour-contract.ts +78 -0
- package/src/rules/warden-export-symmetry.ts +540 -0
- package/src/rules/warden-rules-use-ast.ts +1109 -0
- package/src/rules/webhook-route-collision.ts +306 -0
- package/src/rules/workspace-lock-ownership.ts +34 -0
- package/src/trails/activation-orphan.trail.ts +84 -0
- package/src/trails/captured-kernel.trail.ts +108 -0
- package/src/trails/circular-refs.trail.ts +29 -0
- package/src/trails/cli-command-route-coherence.trail.ts +47 -0
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/dead-internal-trail.trail.ts +26 -0
- package/src/trails/dead-public-trail.trail.ts +31 -0
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/draft-file-marking.trail.ts +16 -0
- package/src/trails/draft-visible-debt.trail.ts +16 -0
- package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
- package/src/trails/duplicate-public-contract.trail.ts +47 -0
- package/src/trails/entity-exists.trail.ts +21 -0
- package/src/trails/error-mapping-completeness.trail.ts +30 -0
- package/src/trails/example-valid.trail.ts +25 -0
- package/src/trails/fires-declarations.trail.ts +23 -0
- package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
- package/src/trails/governed-symbol-residue.trail.ts +24 -0
- package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
- package/src/trails/implementation-returns-result.trail.ts +20 -0
- package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
- package/src/trails/incomplete-crud.trail.ts +39 -0
- package/src/trails/index.ts +91 -0
- package/src/trails/intent-propagation.trail.ts +30 -0
- package/src/trails/layer-field-name-drift.trail.ts +39 -0
- package/src/trails/library-render-coherence.trail.ts +43 -0
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-reconcile.trail.ts +33 -0
- package/src/trails/missing-visibility.trail.ts +22 -0
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
- package/src/trails/no-direct-implementation-call.trail.ts +16 -0
- package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
- package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
- package/src/trails/no-native-error-result.trail.ts +18 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
- package/src/trails/no-sync-result-assumption.trail.ts +19 -0
- package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
- package/src/trails/no-throw-in-implementation.trail.ts +20 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/on-references-exist.trail.ts +21 -0
- package/src/trails/orphaned-signal.trail.ts +36 -0
- package/src/trails/owner-render-parity.trail.ts +26 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/permit-governance.trail.ts +51 -0
- package/src/trails/prefer-schema-inference.trail.ts +21 -0
- package/src/trails/public-export-example-coverage.trail.ts +16 -0
- package/src/trails/public-internal-deep-imports.trail.ts +94 -0
- package/src/trails/public-output-schema.trail.ts +55 -0
- package/src/trails/public-union-output-discriminants.trail.ts +33 -0
- package/src/trails/read-intent-fires.trail.ts +20 -0
- package/src/trails/reference-exists.trail.ts +25 -0
- package/src/trails/resolved-import-boundary.trail.ts +109 -0
- package/src/trails/resource-declarations.trail.ts +25 -0
- package/src/trails/resource-exists.trail.ts +27 -0
- package/src/trails/resource-id-grammar.trail.ts +39 -0
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +205 -0
- package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
- package/src/trails/schema.ts +295 -0
- package/src/trails/signal-graph-coaching.trail.ts +77 -0
- package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
- package/src/trails/surface-overlay-coherence.trail.ts +24 -0
- package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
- package/src/trails/topo.ts +6 -0
- package/src/trails/trail-fork-coaching.trail.ts +42 -0
- package/src/trails/trailhead-override-divergence.trail.ts +47 -0
- package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
- package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
- package/src/trails/valid-describe-refs.trail.ts +18 -0
- package/src/trails/valid-detour-contract.trail.ts +71 -0
- package/src/trails/version-gap.trail.ts +35 -0
- package/src/trails/version-pinned-compose.trail.ts +23 -0
- package/src/trails/version-without-examples.trail.ts +38 -0
- package/src/trails/warden-export-symmetry.trail.ts +16 -0
- package/src/trails/warden-rules-use-ast.trail.ts +64 -0
- package/src/trails/webhook-route-collision.trail.ts +50 -0
- package/src/trails/workspace-lock-ownership.trail.ts +16 -0
- package/src/trails/wrap-rule.ts +269 -0
- 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
|
+
};
|