@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.22

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 (249) hide show
  1. package/CHANGELOG.md +508 -6
  2. package/README.md +77 -26
  3. package/bin/warden.ts +50 -0
  4. package/package.json +27 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +28 -0
  7. package/src/cli.ts +1374 -103
  8. package/src/command.ts +953 -0
  9. package/src/config.ts +184 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +106 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +206 -14
  16. package/src/project-context.ts +163 -0
  17. package/src/resolve.ts +530 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/ast.ts +3176 -85
  20. package/src/rules/circular-refs.ts +154 -0
  21. package/src/rules/composes-declarations.ts +704 -0
  22. package/src/rules/context-no-surface-types.ts +68 -8
  23. package/src/rules/contour-exists.ts +251 -0
  24. package/src/rules/contour-ids.ts +15 -0
  25. package/src/rules/dead-internal-trail.ts +154 -0
  26. package/src/rules/draft-file-marking.ts +160 -0
  27. package/src/rules/draft-visible-debt.ts +87 -0
  28. package/src/rules/error-mapping-completeness.ts +288 -0
  29. package/src/rules/example-valid.ts +401 -0
  30. package/src/rules/fires-declarations.ts +758 -0
  31. package/src/rules/implementation-returns-result.ts +1265 -95
  32. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  33. package/src/rules/incomplete-crud.ts +580 -0
  34. package/src/rules/index.ts +219 -18
  35. package/src/rules/intent-propagation.ts +127 -0
  36. package/src/rules/layer-field-name-drift.ts +96 -0
  37. package/src/rules/metadata.ts +654 -0
  38. package/src/rules/missing-reconcile.ts +98 -0
  39. package/src/rules/missing-visibility.ts +110 -0
  40. package/src/rules/no-destructured-compose.ts +192 -0
  41. package/src/rules/no-dev-permit-in-source.ts +99 -0
  42. package/src/rules/no-direct-implementation-call.ts +7 -7
  43. package/src/rules/no-legacy-layer-imports.ts +211 -0
  44. package/src/rules/no-native-error-result.ts +111 -0
  45. package/src/rules/no-redundant-result-error-wrap.ts +331 -0
  46. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  47. package/src/rules/no-sync-result-assumption.ts +1134 -99
  48. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  49. package/src/rules/no-throw-in-implementation.ts +10 -9
  50. package/src/rules/no-top-level-surface.ts +389 -0
  51. package/src/rules/on-references-exist.ts +194 -0
  52. package/src/rules/orphaned-signal.ts +150 -0
  53. package/src/rules/owner-projection-parity.ts +146 -0
  54. package/src/rules/permit-governance.ts +25 -0
  55. package/src/rules/public-export-example-coverage.ts +553 -0
  56. package/src/rules/public-internal-deep-imports.ts +517 -0
  57. package/src/rules/public-output-schema.ts +29 -0
  58. package/src/rules/public-union-output-discriminants.ts +150 -0
  59. package/src/rules/read-intent-fires.ts +187 -0
  60. package/src/rules/reference-exists.ts +98 -0
  61. package/src/rules/registry-names.ts +145 -0
  62. package/src/rules/resolved-import-boundary.ts +146 -0
  63. package/src/rules/resource-declarations.ts +704 -0
  64. package/src/rules/resource-exists.ts +179 -0
  65. package/src/rules/resource-id-grammar.ts +65 -0
  66. package/src/rules/resource-mock-coverage.ts +115 -0
  67. package/src/rules/scan.ts +38 -25
  68. package/src/rules/scheduled-destroy-intent.ts +44 -0
  69. package/src/rules/signal-graph-coaching.ts +191 -0
  70. package/src/rules/specs.ts +9 -5
  71. package/src/rules/static-resource-accessor-preference.ts +657 -0
  72. package/src/rules/surface-facet-coherence.ts +370 -0
  73. package/src/rules/trail-versioning-source.ts +1094 -0
  74. package/src/rules/trail-versioning-topo.ts +172 -0
  75. package/src/rules/types.ts +270 -6
  76. package/src/rules/unmaterialized-activation-source.ts +84 -0
  77. package/src/rules/unreachable-detour-shadowing.ts +344 -0
  78. package/src/rules/valid-describe-refs.ts +160 -32
  79. package/src/rules/valid-detour-contract.ts +78 -0
  80. package/src/rules/warden-export-symmetry.ts +533 -0
  81. package/src/rules/warden-rules-use-ast.ts +996 -0
  82. package/src/rules/webhook-route-collision.ts +243 -0
  83. package/src/trails/activation-orphan.trail.ts +84 -0
  84. package/src/trails/circular-refs.trail.ts +29 -0
  85. package/src/trails/composes-declarations.trail.ts +22 -0
  86. package/src/trails/context-no-surface-types.trail.ts +21 -0
  87. package/src/trails/contour-exists.trail.ts +21 -0
  88. package/src/trails/dead-internal-trail.trail.ts +26 -0
  89. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  90. package/src/trails/draft-file-marking.trail.ts +16 -0
  91. package/src/trails/draft-visible-debt.trail.ts +16 -0
  92. package/src/trails/error-mapping-completeness.trail.ts +29 -0
  93. package/src/trails/example-valid.trail.ts +25 -0
  94. package/src/trails/fires-declarations.trail.ts +23 -0
  95. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  96. package/src/trails/implementation-returns-result.trail.ts +20 -0
  97. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  98. package/src/trails/incomplete-crud.trail.ts +39 -0
  99. package/src/trails/index.ts +78 -0
  100. package/src/trails/intent-propagation.trail.ts +30 -0
  101. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  102. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  103. package/src/trails/missing-reconcile.trail.ts +33 -0
  104. package/src/trails/missing-visibility.trail.ts +22 -0
  105. package/src/trails/no-destructured-compose.trail.ts +44 -0
  106. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  107. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  108. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  109. package/src/trails/no-native-error-result.trail.ts +18 -0
  110. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  111. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  112. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  113. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  114. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  115. package/src/trails/no-top-level-surface.trail.ts +43 -0
  116. package/src/trails/on-references-exist.trail.ts +21 -0
  117. package/src/trails/orphaned-signal.trail.ts +36 -0
  118. package/src/trails/owner-projection-parity.trail.ts +26 -0
  119. package/src/trails/pending-force.trail.ts +21 -0
  120. package/src/trails/permit-governance.trail.ts +51 -0
  121. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  122. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  123. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  124. package/src/trails/public-output-schema.trail.ts +55 -0
  125. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  126. package/src/trails/read-intent-fires.trail.ts +20 -0
  127. package/src/trails/reference-exists.trail.ts +25 -0
  128. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  129. package/src/trails/resource-declarations.trail.ts +25 -0
  130. package/src/trails/resource-exists.trail.ts +27 -0
  131. package/src/trails/resource-id-grammar.trail.ts +39 -0
  132. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  133. package/src/trails/run.ts +162 -0
  134. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  135. package/src/trails/schema.ts +194 -0
  136. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  137. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  138. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  139. package/src/trails/topo.ts +6 -0
  140. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  141. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  142. package/src/trails/valid-describe-refs.trail.ts +18 -0
  143. package/src/trails/valid-detour-contract.trail.ts +71 -0
  144. package/src/trails/version-gap.trail.ts +35 -0
  145. package/src/trails/version-pinned-compose.trail.ts +23 -0
  146. package/src/trails/version-without-examples.trail.ts +38 -0
  147. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  148. package/src/trails/warden-rules-use-ast.trail.ts +45 -0
  149. package/src/trails/webhook-route-collision.trail.ts +50 -0
  150. package/src/trails/wrap-rule.ts +213 -0
  151. package/src/workspaces.ts +238 -0
  152. package/.turbo/turbo-build.log +0 -1
  153. package/.turbo/turbo-lint.log +0 -3
  154. package/.turbo/turbo-typecheck.log +0 -1
  155. package/dist/cli.d.ts +0 -46
  156. package/dist/cli.d.ts.map +0 -1
  157. package/dist/cli.js +0 -221
  158. package/dist/cli.js.map +0 -1
  159. package/dist/drift.d.ts +0 -26
  160. package/dist/drift.d.ts.map +0 -1
  161. package/dist/drift.js +0 -27
  162. package/dist/drift.js.map +0 -1
  163. package/dist/formatters.d.ts +0 -29
  164. package/dist/formatters.d.ts.map +0 -1
  165. package/dist/formatters.js +0 -87
  166. package/dist/formatters.js.map +0 -1
  167. package/dist/index.d.ts +0 -26
  168. package/dist/index.d.ts.map +0 -1
  169. package/dist/index.js +0 -26
  170. package/dist/index.js.map +0 -1
  171. package/dist/rules/ast.d.ts +0 -41
  172. package/dist/rules/ast.d.ts.map +0 -1
  173. package/dist/rules/ast.js +0 -163
  174. package/dist/rules/ast.js.map +0 -1
  175. package/dist/rules/context-no-surface-types.d.ts +0 -12
  176. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  177. package/dist/rules/context-no-surface-types.js +0 -96
  178. package/dist/rules/context-no-surface-types.js.map +0 -1
  179. package/dist/rules/implementation-returns-result.d.ts +0 -13
  180. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  181. package/dist/rules/implementation-returns-result.js +0 -231
  182. package/dist/rules/implementation-returns-result.js.map +0 -1
  183. package/dist/rules/index.d.ts +0 -22
  184. package/dist/rules/index.d.ts.map +0 -1
  185. package/dist/rules/index.js +0 -41
  186. package/dist/rules/index.js.map +0 -1
  187. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  188. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  189. package/dist/rules/no-direct-impl-in-route.js +0 -46
  190. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  191. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  192. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  193. package/dist/rules/no-direct-implementation-call.js +0 -39
  194. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  195. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  196. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  197. package/dist/rules/no-sync-result-assumption.js +0 -98
  198. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  199. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  200. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  201. package/dist/rules/no-throw-in-detour-target.js +0 -87
  202. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  203. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  204. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  205. package/dist/rules/no-throw-in-implementation.js +0 -34
  206. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  207. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  208. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  209. package/dist/rules/prefer-schema-inference.js +0 -86
  210. package/dist/rules/prefer-schema-inference.js.map +0 -1
  211. package/dist/rules/scan.d.ts +0 -8
  212. package/dist/rules/scan.d.ts.map +0 -1
  213. package/dist/rules/scan.js +0 -32
  214. package/dist/rules/scan.js.map +0 -1
  215. package/dist/rules/specs.d.ts +0 -29
  216. package/dist/rules/specs.d.ts.map +0 -1
  217. package/dist/rules/specs.js +0 -192
  218. package/dist/rules/specs.js.map +0 -1
  219. package/dist/rules/structure.d.ts +0 -13
  220. package/dist/rules/structure.d.ts.map +0 -1
  221. package/dist/rules/structure.js +0 -142
  222. package/dist/rules/structure.js.map +0 -1
  223. package/dist/rules/types.d.ts +0 -52
  224. package/dist/rules/types.d.ts.map +0 -1
  225. package/dist/rules/types.js +0 -2
  226. package/dist/rules/types.js.map +0 -1
  227. package/dist/rules/valid-describe-refs.d.ts +0 -7
  228. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  229. package/dist/rules/valid-describe-refs.js +0 -51
  230. package/dist/rules/valid-describe-refs.js.map +0 -1
  231. package/dist/rules/valid-detour-refs.d.ts +0 -6
  232. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  233. package/dist/rules/valid-detour-refs.js +0 -116
  234. package/dist/rules/valid-detour-refs.js.map +0 -1
  235. package/src/__tests__/cli.test.ts +0 -198
  236. package/src/__tests__/drift.test.ts +0 -74
  237. package/src/__tests__/formatters.test.ts +0 -157
  238. package/src/__tests__/implementation-returns-result.test.ts +0 -75
  239. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  240. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  241. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  242. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  243. package/src/__tests__/rules.test.ts +0 -188
  244. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  245. package/src/rules/no-direct-impl-in-route.ts +0 -77
  246. package/src/rules/no-throw-in-detour-target.ts +0 -150
  247. package/src/rules/valid-detour-refs.ts +0 -187
  248. package/tsconfig.json +0 -9
  249. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,580 @@
1
+ import { crudOperations } from '@ontrails/store';
2
+
3
+ import {
4
+ collectNamedContourIds,
5
+ collectNamedStoreTableIds,
6
+ deriveStoreTableId,
7
+ getStringValue,
8
+ identifierName,
9
+ isNamedCall,
10
+ isStringLiteral,
11
+ offsetToLine,
12
+ parse,
13
+ walk,
14
+ } from './ast.js';
15
+ import type { AstNode } from './ast.js';
16
+ import { isTestFile } from './scan.js';
17
+ import type {
18
+ ProjectAwareWardenRule,
19
+ ProjectContext,
20
+ WardenDiagnostic,
21
+ } from './types.js';
22
+
23
+ const CRUD_OPERATION_SET = new Set<string>(crudOperations);
24
+
25
+ /** Sentinel entity id prefix for contours imported from another module. */
26
+ const IMPORTED_CONTOUR_PREFIX = 'imported:';
27
+ const IMPORTED_CONTOUR_SOURCE_SEPARATOR = '#';
28
+ const CONTOUR_BINDING_SUFFIX = 'Contour';
29
+
30
+ interface CrudCoverage {
31
+ readonly entityId: string;
32
+ readonly line: number;
33
+ readonly operations: Set<string>;
34
+ }
35
+
36
+ interface ImportAliasResolution {
37
+ readonly importedName: string;
38
+ readonly source: string;
39
+ }
40
+
41
+ const getImportSource = (node: AstNode): string | null => {
42
+ const sourceNode = (node as unknown as { source?: AstNode }).source;
43
+ return sourceNode && isStringLiteral(sourceNode)
44
+ ? getStringValue(sourceNode)
45
+ : null;
46
+ };
47
+
48
+ const extractImportAliasResolution = (
49
+ specifier: AstNode,
50
+ source: string
51
+ ): {
52
+ readonly localName: string;
53
+ readonly resolution: ImportAliasResolution;
54
+ } | null => {
55
+ if (specifier.type !== 'ImportSpecifier') {
56
+ return null;
57
+ }
58
+
59
+ const { imported } = specifier as unknown as { imported?: AstNode };
60
+ const { local } = specifier as unknown as { local?: AstNode };
61
+ const localName = identifierName(local);
62
+ if (!localName) {
63
+ return null;
64
+ }
65
+
66
+ const importedName = imported
67
+ ? (identifierName(imported) ??
68
+ (isStringLiteral(imported) ? getStringValue(imported) : null))
69
+ : null;
70
+ return {
71
+ localName,
72
+ resolution: {
73
+ importedName: importedName ?? localName,
74
+ source,
75
+ },
76
+ };
77
+ };
78
+
79
+ const collectImportDeclarationAliases = (
80
+ node: AstNode,
81
+ aliases: Map<string, ImportAliasResolution>
82
+ ): void => {
83
+ const source = getImportSource(node);
84
+ if (!source) {
85
+ return;
86
+ }
87
+
88
+ const specifiers = ((node as unknown as { specifiers?: readonly AstNode[] })
89
+ .specifiers ?? []) as readonly AstNode[];
90
+ for (const specifier of specifiers) {
91
+ const alias = extractImportAliasResolution(specifier, source);
92
+ if (!alias) {
93
+ continue;
94
+ }
95
+ aliases.set(alias.localName, alias.resolution);
96
+ }
97
+ };
98
+
99
+ const extractInlineContourId = (node: AstNode | undefined): string | null => {
100
+ if (!isNamedCall(node, 'contour')) {
101
+ return null;
102
+ }
103
+
104
+ const [nameArg] = ((node as unknown as { arguments?: readonly AstNode[] })
105
+ .arguments ?? []) as readonly AstNode[];
106
+ return nameArg && isStringLiteral(nameArg) ? getStringValue(nameArg) : null;
107
+ };
108
+
109
+ const collectImportAliasResolutions = (
110
+ ast: AstNode
111
+ ): ReadonlyMap<string, ImportAliasResolution> => {
112
+ const aliases = new Map<string, ImportAliasResolution>();
113
+
114
+ walk(ast, (node) => {
115
+ if (node.type !== 'ImportDeclaration') {
116
+ return;
117
+ }
118
+ collectImportDeclarationAliases(node, aliases);
119
+ });
120
+
121
+ return aliases;
122
+ };
123
+
124
+ const buildImportedContourId = (source: string, importedName: string): string =>
125
+ `${IMPORTED_CONTOUR_PREFIX}${source}${IMPORTED_CONTOUR_SOURCE_SEPARATOR}${importedName}`;
126
+
127
+ const parseImportedContourId = (
128
+ entityId: string
129
+ ): { readonly bindingName: string; readonly source?: string } | null => {
130
+ if (!entityId.startsWith(IMPORTED_CONTOUR_PREFIX)) {
131
+ return null;
132
+ }
133
+
134
+ const remainder = entityId.slice(IMPORTED_CONTOUR_PREFIX.length);
135
+ const separator = remainder.lastIndexOf(IMPORTED_CONTOUR_SOURCE_SEPARATOR);
136
+ if (separator === -1) {
137
+ return { bindingName: remainder };
138
+ }
139
+
140
+ return {
141
+ bindingName: remainder.slice(separator + 1),
142
+ source: remainder.slice(0, separator),
143
+ };
144
+ };
145
+
146
+ /**
147
+ * Resolve an identifier reference (bound contour or imported alias) to a
148
+ * stable entity id. Imported identifiers return a `pending-resolution`
149
+ * sentinel so coverage is still tracked instead of silently dropped.
150
+ */
151
+ const resolveContourIdentifier = (
152
+ name: string,
153
+ namedContourIds: ReadonlyMap<string, string>,
154
+ importAliases: ReadonlyMap<string, ImportAliasResolution>
155
+ ): string | null => {
156
+ const local = namedContourIds.get(name);
157
+ if (local) {
158
+ return local;
159
+ }
160
+
161
+ const imported = importAliases.get(name);
162
+ if (imported) {
163
+ return buildImportedContourId(imported.source, imported.importedName);
164
+ }
165
+
166
+ return null;
167
+ };
168
+
169
+ const stripContourBindingSuffix = (entityId: string): string =>
170
+ entityId.endsWith(CONTOUR_BINDING_SUFFIX)
171
+ ? entityId.slice(0, -CONTOUR_BINDING_SUFFIX.length)
172
+ : entityId;
173
+
174
+ const normalizeProjectEntityId = (
175
+ entityId: string,
176
+ projectEntityIds: ReadonlySet<string>
177
+ ): string => {
178
+ const imported = parseImportedContourId(entityId);
179
+ if (!imported) {
180
+ return entityId;
181
+ }
182
+
183
+ const localId = imported.bindingName;
184
+ if (projectEntityIds.has(localId)) {
185
+ return localId;
186
+ }
187
+ const strippedId = stripContourBindingSuffix(localId);
188
+ if (
189
+ strippedId !== localId &&
190
+ (projectEntityIds.has(strippedId) ||
191
+ projectEntityIds.has(`${IMPORTED_CONTOUR_PREFIX}${strippedId}`))
192
+ ) {
193
+ return strippedId;
194
+ }
195
+ return entityId;
196
+ };
197
+
198
+ const normalizeProjectCoverage = (
199
+ projectCoverage: ReadonlyMap<string, ReadonlySet<string>>,
200
+ projectEntityIds: ReadonlySet<string>
201
+ ): ReadonlyMap<string, ReadonlySet<string>> => {
202
+ const normalized = new Map<string, Set<string>>();
203
+ for (const [entityId, operations] of projectCoverage) {
204
+ const normalizedId = normalizeProjectEntityId(entityId, projectEntityIds);
205
+ const bucket = normalized.get(normalizedId) ?? new Set<string>();
206
+ for (const operation of operations) {
207
+ bucket.add(operation);
208
+ }
209
+ normalized.set(normalizedId, bucket);
210
+ }
211
+ return normalized;
212
+ };
213
+
214
+ /**
215
+ * Resolve a `deriveTrail` contour argument to a stable entity id.
216
+ *
217
+ * Resolution order:
218
+ * 1. Inline `contour('name', …)` call — use the authored name.
219
+ * 2. Local identifier bound to `contour('name', …)` via `namedContourIds`.
220
+ * 3. Identifier imported from another module — mark as a pending
221
+ * `imported:<local>` coverage observation so the rule still tracks the
222
+ * entity across the file instead of silently dropping it. The prefix is
223
+ * stripped from diagnostic output for readability.
224
+ */
225
+ const resolveContourId = (
226
+ node: AstNode | undefined,
227
+ namedContourIds: ReadonlyMap<string, string>,
228
+ importAliases: ReadonlyMap<string, ImportAliasResolution>
229
+ ): string | null => {
230
+ if (!node) {
231
+ return null;
232
+ }
233
+
234
+ if (node.type === 'Identifier') {
235
+ const name = identifierName(node);
236
+ return name
237
+ ? resolveContourIdentifier(name, namedContourIds, importAliases)
238
+ : null;
239
+ }
240
+
241
+ return extractInlineContourId(node);
242
+ };
243
+
244
+ const ensureCoverage = (
245
+ coverageByEntityId: Map<string, CrudCoverage>,
246
+ entityId: string,
247
+ line: number
248
+ ): CrudCoverage => {
249
+ const existing = coverageByEntityId.get(entityId);
250
+ if (existing) {
251
+ return existing;
252
+ }
253
+
254
+ const coverage: CrudCoverage = {
255
+ entityId,
256
+ line,
257
+ operations: new Set<string>(),
258
+ };
259
+ coverageByEntityId.set(entityId, coverage);
260
+ return coverage;
261
+ };
262
+
263
+ const extractCrudOperation = (node: AstNode | undefined): string | null => {
264
+ if (!node || !isStringLiteral(node)) {
265
+ return null;
266
+ }
267
+
268
+ const operation = getStringValue(node);
269
+ return operation && CRUD_OPERATION_SET.has(operation) ? operation : null;
270
+ };
271
+
272
+ const extractDerivedCrudEntry = (
273
+ node: AstNode,
274
+ namedContourIds: ReadonlyMap<string, string>,
275
+ importAliases: ReadonlyMap<string, ImportAliasResolution>
276
+ ): { readonly entityId: string; readonly operation: string } | null => {
277
+ if (!isNamedCall(node, 'deriveTrail')) {
278
+ return null;
279
+ }
280
+
281
+ const [contourArg, operationArg] = ((
282
+ node as unknown as {
283
+ arguments?: readonly AstNode[];
284
+ }
285
+ ).arguments ?? []) as readonly AstNode[];
286
+ const operation = extractCrudOperation(operationArg);
287
+ const entityId = resolveContourId(contourArg, namedContourIds, importAliases);
288
+ return operation && entityId ? { entityId, operation } : null;
289
+ };
290
+
291
+ const collectDerivedCrudCoverage = (
292
+ ast: AstNode,
293
+ sourceCode: string
294
+ ): ReadonlyMap<string, CrudCoverage> => {
295
+ const coverageByEntityId = new Map<string, CrudCoverage>();
296
+ const namedContourIds = collectNamedContourIds(ast);
297
+ const importAliases = collectImportAliasResolutions(ast);
298
+
299
+ walk(ast, (node) => {
300
+ const entry = extractDerivedCrudEntry(node, namedContourIds, importAliases);
301
+ if (!entry) {
302
+ return;
303
+ }
304
+
305
+ ensureCoverage(
306
+ coverageByEntityId,
307
+ entry.entityId,
308
+ offsetToLine(sourceCode, node.start)
309
+ ).operations.add(entry.operation);
310
+ });
311
+
312
+ return coverageByEntityId;
313
+ };
314
+
315
+ const collectTupleOperations = (
316
+ elements: readonly AstNode[]
317
+ ): readonly string[] =>
318
+ // Array-pattern elisions are represented as null by OXC today; the truthy
319
+ // check below intentionally treats those the same as out-of-bounds slots.
320
+ // If OXC ever switches to a non-null placeholder node, update this to an
321
+ // explicit null check so elisions still count as absent.
322
+ crudOperations.flatMap((operation, index) =>
323
+ elements[index] ? [operation] : []
324
+ );
325
+
326
+ const extractCrudTuplePattern = (
327
+ node: AstNode,
328
+ namedStoreTableIds: ReadonlyMap<string, string>
329
+ ): {
330
+ readonly elements: readonly AstNode[];
331
+ readonly entityId: string;
332
+ } | null => {
333
+ if (node.type !== 'VariableDeclarator') {
334
+ return null;
335
+ }
336
+
337
+ const { id, init } = node as unknown as {
338
+ readonly id?: AstNode;
339
+ readonly init?: AstNode;
340
+ };
341
+ if (!id || id.type !== 'ArrayPattern' || !isNamedCall(init, 'crud')) {
342
+ return null;
343
+ }
344
+
345
+ const [tableArg] = ((init as unknown as { arguments?: readonly AstNode[] })
346
+ .arguments ?? []) as readonly AstNode[];
347
+ const entityId = deriveStoreTableId(tableArg, namedStoreTableIds);
348
+ const { elements } = id as unknown as { elements?: readonly AstNode[] };
349
+ return entityId && elements ? { elements, entityId } : null;
350
+ };
351
+
352
+ const extractCrudTupleEntry = (
353
+ node: AstNode,
354
+ namedStoreTableIds: ReadonlyMap<string, string>
355
+ ): {
356
+ readonly entityId: string;
357
+ readonly operations: readonly string[];
358
+ } | null => {
359
+ const pattern = extractCrudTuplePattern(node, namedStoreTableIds);
360
+ if (!pattern) {
361
+ return null;
362
+ }
363
+
364
+ return {
365
+ entityId: pattern.entityId,
366
+ operations: collectTupleOperations(pattern.elements),
367
+ };
368
+ };
369
+
370
+ const collectCrudTupleCoverage = (
371
+ ast: AstNode,
372
+ sourceCode: string
373
+ ): ReadonlyMap<string, CrudCoverage> => {
374
+ const coverageByEntityId = new Map<string, CrudCoverage>();
375
+ const namedStoreTableIds = collectNamedStoreTableIds(ast);
376
+
377
+ walk(ast, (node) => {
378
+ const entry = extractCrudTupleEntry(node, namedStoreTableIds);
379
+ if (!entry) {
380
+ return;
381
+ }
382
+
383
+ const coverage = ensureCoverage(
384
+ coverageByEntityId,
385
+ entry.entityId,
386
+ offsetToLine(sourceCode, node.start)
387
+ );
388
+
389
+ for (const operation of entry.operations) {
390
+ coverage.operations.add(operation);
391
+ }
392
+ });
393
+
394
+ return coverageByEntityId;
395
+ };
396
+
397
+ const collectFileCoverage = (
398
+ ast: AstNode,
399
+ sourceCode: string
400
+ ): {
401
+ readonly derived: ReadonlyMap<string, CrudCoverage>;
402
+ readonly tuple: ReadonlyMap<string, CrudCoverage>;
403
+ } => ({
404
+ derived: collectDerivedCrudCoverage(ast, sourceCode),
405
+ tuple: collectCrudTupleCoverage(ast, sourceCode),
406
+ });
407
+
408
+ /**
409
+ * Public AST helper: collect per-entity CRUD operation coverage for a single
410
+ * file. Used by the CLI to aggregate coverage across the project before the
411
+ * rule runs, so one-file-per-operation layouts are evaluated correctly.
412
+ */
413
+ export const collectFileCrudCoverage = (
414
+ ast: AstNode,
415
+ sourceCode: string
416
+ ): ReadonlyMap<string, ReadonlySet<string>> => {
417
+ const { derived, tuple } = collectFileCoverage(ast, sourceCode);
418
+ const merged = new Map<string, Set<string>>();
419
+ const merge = (source: ReadonlyMap<string, CrudCoverage>): void => {
420
+ for (const [entityId, coverage] of source) {
421
+ const bucket = merged.get(entityId) ?? new Set<string>();
422
+ for (const operation of coverage.operations) {
423
+ bucket.add(operation);
424
+ }
425
+ merged.set(entityId, bucket);
426
+ }
427
+ };
428
+ merge(derived);
429
+ merge(tuple);
430
+ return merged;
431
+ };
432
+
433
+ const seedCombinedCoverage = (
434
+ fileCoverage: ReadonlyMap<string, CrudCoverage>
435
+ ): Map<string, Set<string>> => {
436
+ const combined = new Map<string, Set<string>>();
437
+ for (const [entityId, coverage] of fileCoverage) {
438
+ combined.set(entityId, new Set(coverage.operations));
439
+ }
440
+ return combined;
441
+ };
442
+
443
+ const applyProjectOperations = (
444
+ combined: Map<string, Set<string>>,
445
+ fileCoverage: ReadonlyMap<string, CrudCoverage>,
446
+ projectCoverage: ReadonlyMap<string, ReadonlySet<string>>
447
+ ): void => {
448
+ const projectEntityIds = new Set(projectCoverage.keys());
449
+ const normalizedProjectCoverage = normalizeProjectCoverage(
450
+ projectCoverage,
451
+ projectEntityIds
452
+ );
453
+ for (const entityId of fileCoverage.keys()) {
454
+ const bucket = combined.get(entityId) ?? new Set<string>();
455
+ const operations = normalizedProjectCoverage.get(
456
+ normalizeProjectEntityId(entityId, projectEntityIds)
457
+ );
458
+ if (operations) {
459
+ for (const operation of operations) {
460
+ bucket.add(operation);
461
+ }
462
+ }
463
+ combined.set(entityId, bucket);
464
+ }
465
+ };
466
+
467
+ const mergeProjectOperations = (
468
+ fileCoverage: ReadonlyMap<string, CrudCoverage>,
469
+ projectCoverage?: ReadonlyMap<string, ReadonlySet<string>>
470
+ ): Map<string, Set<string>> => {
471
+ const combined = seedCombinedCoverage(fileCoverage);
472
+ if (projectCoverage) {
473
+ applyProjectOperations(combined, fileCoverage, projectCoverage);
474
+ }
475
+ return combined;
476
+ };
477
+
478
+ const collectIncompleteEntities = (
479
+ fileCoverage: ReadonlyMap<string, CrudCoverage>,
480
+ projectCoverage?: ReadonlyMap<string, ReadonlySet<string>>
481
+ ): readonly CrudCoverage[] => {
482
+ const combinedOperations = mergeProjectOperations(
483
+ fileCoverage,
484
+ projectCoverage
485
+ );
486
+
487
+ return [...fileCoverage.values()].flatMap((coverage) => {
488
+ const combined = combinedOperations.get(coverage.entityId);
489
+ if (!combined || combined.size === 0) {
490
+ return [];
491
+ }
492
+ if (combined.size >= crudOperations.length) {
493
+ return [];
494
+ }
495
+ return [
496
+ {
497
+ entityId: coverage.entityId,
498
+ line: coverage.line,
499
+ operations: combined,
500
+ },
501
+ ];
502
+ });
503
+ };
504
+
505
+ const formatEntityLabel = (entityId: string): string => {
506
+ const imported = parseImportedContourId(entityId);
507
+ return imported
508
+ ? `${imported.bindingName} (imported, pending-resolution)`
509
+ : entityId;
510
+ };
511
+
512
+ const buildIncompleteCrudDiagnostic = (
513
+ coverage: CrudCoverage,
514
+ filePath: string
515
+ ): WardenDiagnostic => {
516
+ const present = crudOperations.filter((operation) =>
517
+ coverage.operations.has(operation)
518
+ );
519
+ const missing = crudOperations.filter(
520
+ (operation) => !coverage.operations.has(operation)
521
+ );
522
+
523
+ return {
524
+ filePath,
525
+ line: coverage.line,
526
+ message: `Factory coverage for "${formatEntityLabel(coverage.entityId)}" is incomplete: found ${present.join(', ')} but missing ${missing.join(', ')}. Prefer the full CRUD set or document the intentional omission.`,
527
+ rule: 'incomplete-crud',
528
+ severity: 'warn',
529
+ };
530
+ };
531
+
532
+ const evaluateFile = (
533
+ sourceCode: string,
534
+ filePath: string,
535
+ projectCoverage?: ReadonlyMap<string, ReadonlySet<string>>
536
+ ): readonly WardenDiagnostic[] => {
537
+ if (isTestFile(filePath)) {
538
+ return [];
539
+ }
540
+
541
+ const ast = parse(filePath, sourceCode);
542
+ if (!ast) {
543
+ return [];
544
+ }
545
+
546
+ const { derived, tuple } = collectFileCoverage(ast, sourceCode);
547
+
548
+ return [
549
+ ...collectIncompleteEntities(derived, projectCoverage),
550
+ ...collectIncompleteEntities(tuple, projectCoverage),
551
+ ].map((coverage) => buildIncompleteCrudDiagnostic(coverage, filePath));
552
+ };
553
+
554
+ /**
555
+ * Warn when factory-style CRUD authoring covers only part of the standard
556
+ * create/read/update/delete/list set.
557
+ *
558
+ * Project-aware: when a `ProjectContext` is available, operations observed in
559
+ * sibling files (e.g. one-file-per-operation layouts such as `create.ts`,
560
+ * `read.ts`, `update.ts`, `delete.ts`, `list.ts`) are merged with the local
561
+ * file's coverage before completeness is evaluated, so split layouts do not
562
+ * produce false positives. The fallback `check` entry point stays file-scoped
563
+ * for direct invocations that lack project context.
564
+ */
565
+ export const incompleteCrud: ProjectAwareWardenRule = {
566
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
567
+ return evaluateFile(sourceCode, filePath);
568
+ },
569
+ checkWithContext(
570
+ sourceCode: string,
571
+ filePath: string,
572
+ context: ProjectContext
573
+ ): readonly WardenDiagnostic[] {
574
+ return evaluateFile(sourceCode, filePath, context.crudCoverageByEntity);
575
+ },
576
+ description:
577
+ 'Warn when factory-style CRUD authoring covers only part of the standard create/read/update/delete/list set. Coverage is aggregated across the project so one-file-per-operation layouts are evaluated on the full CRUD set.',
578
+ name: 'incomplete-crud',
579
+ severity: 'warn',
580
+ };