@ontrails/warden 1.0.0-beta.4 → 1.0.0-beta.41

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