@ontrails/warden 1.0.0-beta.3 → 1.0.0-beta.32

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