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

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 +837 -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,618 @@
1
+ /** Warden-private entity reference and user-namespace collectors. */
2
+
3
+ import {
4
+ buildFrameworkNamespaceContext,
5
+ extractEntityDefinition,
6
+ extractStringLiteral,
7
+ findEntityDefinitions,
8
+ getImportSourceValue,
9
+ getPropertyName,
10
+ identifierName,
11
+ isFrameworkNamespaceSource,
12
+ isMemberAccessNonComputed,
13
+ isShadowed,
14
+ walk,
15
+ walkWithScopes,
16
+ } from '@ontrails/source';
17
+ import type {
18
+ AstNode,
19
+ EntityDefinition,
20
+ FrameworkNamespaceContext,
21
+ } from '@ontrails/source';
22
+
23
+ export const collectEntityDefinitionIds = (ast: AstNode): ReadonlySet<string> =>
24
+ new Set(findEntityDefinitions(ast).map((def) => def.name));
25
+
26
+ /**
27
+ * Collect the `localBinding → entityName` map for `const foo = entity(...)`
28
+ * declarations. Inline entity calls are intentionally excluded because they
29
+ * have no local binding — use {@link collectEntityDefinitionIds} when the
30
+ * full set of declared names is required.
31
+ */
32
+ export const collectNamedEntityIds = (
33
+ ast: AstNode
34
+ ): ReadonlyMap<string, string> => {
35
+ const ids = new Map<string, string>();
36
+
37
+ for (const def of findEntityDefinitions(ast)) {
38
+ if (def.bindingName) {
39
+ ids.set(def.bindingName, def.name);
40
+ }
41
+ }
42
+
43
+ return ids;
44
+ };
45
+
46
+ const resolveNamedImportedName = (
47
+ specifier: AstNode,
48
+ localName: string
49
+ ): string => {
50
+ const { imported } = specifier as unknown as { imported?: AstNode };
51
+ const importedName = imported
52
+ ? (identifierName(imported) ?? extractStringLiteral(imported))
53
+ : null;
54
+ return importedName ?? localName;
55
+ };
56
+
57
+ const extractImportSpecifierAlias = (
58
+ specifier: AstNode
59
+ ): { readonly localName: string; readonly importedName: string } | null => {
60
+ if (
61
+ specifier.type !== 'ImportSpecifier' &&
62
+ specifier.type !== 'ImportDefaultSpecifier'
63
+ ) {
64
+ return null;
65
+ }
66
+
67
+ const { local } = specifier as unknown as { local?: AstNode };
68
+ const localName = identifierName(local);
69
+ if (!localName) {
70
+ return null;
71
+ }
72
+
73
+ // Default imports bind the default export of the source module to the local
74
+ // name. We cannot statically recover the exported name without compose-file
75
+ // analysis, so the local name is the best identifier we have for resolving
76
+ // against `knownEntityIds`. Treat the alias as an identity mapping; the
77
+ // downstream resolver will fall through to `knownEntityIds` on the binding
78
+ // name and report it as missing when not found.
79
+ if (specifier.type === 'ImportDefaultSpecifier') {
80
+ return { importedName: localName, localName };
81
+ }
82
+
83
+ return {
84
+ importedName: resolveNamedImportedName(specifier, localName),
85
+ localName,
86
+ };
87
+ };
88
+
89
+ /**
90
+ * Collect `import {
91
+ foo as bar
92
+ } from '...';` and `import bar from '...'`
93
+ * specifier mappings keyed by local binding name. The value is the original
94
+ * exported name for named imports. Default imports map to themselves because
95
+ * the exported name cannot be recovered statically — callers should fall
96
+ * through to `knownEntityIds` membership on the local binding name.
97
+ */
98
+ export const collectImportAliasMap = (
99
+ ast: AstNode
100
+ ): ReadonlyMap<string, string> => {
101
+ const aliases = new Map<string, string>();
102
+
103
+ walk(ast, (node) => {
104
+ if (node.type !== 'ImportDeclaration') {
105
+ return;
106
+ }
107
+
108
+ const specifiers =
109
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
110
+ for (const specifier of specifiers) {
111
+ const alias = extractImportSpecifierAlias(specifier);
112
+ if (alias) {
113
+ aliases.set(alias.localName, alias.importedName);
114
+ }
115
+ }
116
+ });
117
+
118
+ return aliases;
119
+ };
120
+
121
+ const addUserNamespaceBindingsFromDeclaration = (
122
+ node: AstNode,
123
+ into: Set<string>
124
+ ): void => {
125
+ if (isFrameworkNamespaceSource(getImportSourceValue(node))) {
126
+ return;
127
+ }
128
+ const specifiers =
129
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
130
+ for (const specifier of specifiers) {
131
+ if (specifier.type !== 'ImportNamespaceSpecifier') {
132
+ continue;
133
+ }
134
+ const { local } = specifier as unknown as { local?: AstNode };
135
+ const localName = identifierName(local);
136
+ if (localName) {
137
+ into.add(localName);
138
+ }
139
+ }
140
+ };
141
+
142
+ /**
143
+ * Collect local binding names introduced by `import * as <name> from '<src>'`
144
+ * declarations whose source is NOT an `@ontrails/*` framework package. These
145
+ * are user-defined namespace imports of entity modules (e.g. `import * as
146
+ * entities from './entities'`), used to resolve `entities.user` member-access
147
+ * references to entity ids.
148
+ *
149
+ * Framework namespace imports (`import * as core from '@ontrails/core'`) are
150
+ * intentionally excluded — they carry framework primitives like
151
+ * `core.entity(...)` and are resolved by {@link buildFrameworkNamespaceContext}.
152
+ * Mixing them here would treat `core.entity` as a reference to an entity
153
+ * named "entity", producing false positives.
154
+ */
155
+ export const collectUserNamespaceImportBindings = (
156
+ ast: AstNode
157
+ ): ReadonlySet<string> => {
158
+ const bindings = new Set<string>();
159
+
160
+ walk(ast, (node) => {
161
+ if (node.type !== 'ImportDeclaration') {
162
+ return;
163
+ }
164
+ addUserNamespaceBindingsFromDeclaration(node, bindings);
165
+ });
166
+
167
+ return bindings;
168
+ };
169
+
170
+ /**
171
+ * Resolution context for user-namespace member access like `entities.user`.
172
+ * Bundles the set of local namespace-binding names (from `import * as x from
173
+ * './entities'`) with an optional set of proven-safe `MemberExpression` start
174
+ * offsets from a scope-aware pre-pass. When `safeMemberStarts` is present, a
175
+ * member access only resolves to a user-namespace target if its start is in
176
+ * the set — so a function-local shadow of the namespace import does not leak
177
+ * through. When absent, the name-only gate is used as a
178
+ * backward-compatible fallback for ad-hoc callers.
179
+ */
180
+ export interface UserNamespaceContext {
181
+ readonly bindings: ReadonlySet<string>;
182
+ readonly safeMemberStarts?: ReadonlySet<number>;
183
+ }
184
+
185
+ /**
186
+ * Walk the AST with a scope stack and collect `MemberExpression` start offsets
187
+ * whose receiver is a user-namespace binding that is NOT shadowed by any
188
+ * enclosing scope. Mirrors `collectFrameworkNamespacedCallStarts` for the
189
+ * framework-namespace path so `entities.user` inside
190
+ * `function f(entities) { ... }` is rejected as shadowed.
191
+ */
192
+ /**
193
+ * Return the receiver-identifier name of a non-computed member access, or
194
+ * `null` for any other node shape (computed access, non-member, etc.).
195
+ */
196
+ const getNonComputedMemberReceiver = (node: AstNode): string | null => {
197
+ if (!isMemberAccessNonComputed(node)) {
198
+ return null;
199
+ }
200
+ const { object } = node as unknown as { object?: AstNode };
201
+ return object ? identifierName(object) : null;
202
+ };
203
+
204
+ const collectUserNamespacedMemberStarts = (
205
+ ast: AstNode,
206
+ bindings: ReadonlySet<string>
207
+ ): ReadonlySet<number> => {
208
+ const starts = new Set<number>();
209
+ if (bindings.size === 0) {
210
+ return starts;
211
+ }
212
+
213
+ walkWithScopes(ast, (node, scopes) => {
214
+ const receiver = getNonComputedMemberReceiver(node);
215
+ if (!receiver || !bindings.has(receiver) || isShadowed(receiver, scopes)) {
216
+ return;
217
+ }
218
+ starts.add(node.start);
219
+ });
220
+
221
+ return starts;
222
+ };
223
+
224
+ /**
225
+ * Build a {@link UserNamespaceContext} for `ast`, including the scope-aware
226
+ * `safeMemberStarts` gate. Prefer this over bare
227
+ * {@link collectUserNamespaceImportBindings} so member access like
228
+ * `entities.user` is rejected when `entities` is shadowed by a local binding.
229
+ */
230
+ export const buildUserNamespaceContext = (
231
+ ast: AstNode
232
+ ): UserNamespaceContext => {
233
+ const bindings = collectUserNamespaceImportBindings(ast);
234
+ return {
235
+ bindings,
236
+ safeMemberStarts: collectUserNamespacedMemberStarts(ast, bindings),
237
+ };
238
+ };
239
+
240
+ export interface EntityReferenceSite {
241
+ /** Field on the source entity that declares the reference. */
242
+ readonly field: string;
243
+ /** Source entity name. */
244
+ readonly source: string;
245
+ /** Start offset of the field declaration. */
246
+ readonly start: number;
247
+ /** Target entity name. */
248
+ readonly target: string;
249
+ }
250
+
251
+ const stripEntitySuffix = (name: string): string => {
252
+ const suffix = 'Entity';
253
+ return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
254
+ };
255
+
256
+ const resolveKnownEntityName = (
257
+ name: string,
258
+ knownEntityIds?: ReadonlySet<string>
259
+ ): string | null => {
260
+ if (knownEntityIds?.has(name)) {
261
+ return name;
262
+ }
263
+
264
+ // Support the common `const userEntity = entity('user', ...)` naming
265
+ // pattern when callers refer to the binding name instead of the entity ID.
266
+ // Exact matches always win; suffix stripping is a fallback only.
267
+ const stripped = stripEntitySuffix(name);
268
+ if (stripped !== name && knownEntityIds?.has(stripped)) {
269
+ return stripped;
270
+ }
271
+
272
+ return null;
273
+ };
274
+
275
+ /**
276
+ * Resolve a local binding name to an entity ID, honoring import aliases.
277
+ *
278
+ * Strategies, in order:
279
+ * 1. Local `const foo = entity('name', ...)` binding → the entity name.
280
+ * 2. `knownEntityIds` membership on the binding name itself (or the
281
+ * conventional `Entity` suffix strip).
282
+ * 3. `import { foo as bar }` → use the original exported name `foo`
283
+ * (and apply strategy 2 / suffix-stripping against it so aliased imports
284
+ * resolve correctly). If the imported name still isn't recognized, the
285
+ * imported name is returned so the caller can report it missing.
286
+ *
287
+ * Returns `null` only when the name belongs to no known resolution path —
288
+ * no local binding, no known entity ID, no import, and no suffix match.
289
+ * Returning `null` means "this identifier is not an entity reference we can
290
+ * reason about" (e.g. a bare undeclared variable), as opposed to
291
+ * "an entity reference whose target is missing".
292
+ */
293
+ export const deriveEntityIdentifierName = (
294
+ bindingName: string,
295
+ namedEntityIds: ReadonlyMap<string, string>,
296
+ knownEntityIds?: ReadonlySet<string>,
297
+ importAliases?: ReadonlyMap<string, string>
298
+ ): string | null => {
299
+ const localName = namedEntityIds.get(bindingName);
300
+ if (localName) {
301
+ return localName;
302
+ }
303
+
304
+ const known = resolveKnownEntityName(bindingName, knownEntityIds);
305
+ if (known) {
306
+ return known;
307
+ }
308
+
309
+ // If the binding came from an import, use the original exported name as
310
+ // the resolution target. This lets `import { foo as bar }` resolve to
311
+ // the exported `foo` rather than the local alias `bar`. If the imported
312
+ // name still isn't recognized, return it so callers can report it as
313
+ // missing under its original name.
314
+ const importedName = importAliases?.get(bindingName);
315
+ if (importedName) {
316
+ return resolveKnownEntityName(importedName, knownEntityIds) ?? importedName;
317
+ }
318
+
319
+ return null;
320
+ };
321
+
322
+ const getEntityReferenceMember = (
323
+ node: AstNode
324
+ ): {
325
+ readonly object?: AstNode;
326
+ readonly property?: AstNode;
327
+ readonly start: number;
328
+ } | null => {
329
+ if (
330
+ node.type !== 'MemberExpression' &&
331
+ node.type !== 'StaticMemberExpression'
332
+ ) {
333
+ return null;
334
+ }
335
+
336
+ return node as unknown as {
337
+ readonly object?: AstNode;
338
+ readonly property?: AstNode;
339
+ readonly start: number;
340
+ };
341
+ };
342
+
343
+ const asUserNamespaceContext = (
344
+ input: ReadonlySet<string> | UserNamespaceContext | undefined
345
+ ): UserNamespaceContext | undefined => {
346
+ if (!input) {
347
+ return undefined;
348
+ }
349
+ return input instanceof Set
350
+ ? { bindings: input }
351
+ : (input as UserNamespaceContext);
352
+ };
353
+
354
+ /**
355
+ * Resolve a user-namespace member access like `entities.user` to its entity
356
+ * id. Returns the property name (e.g. `'user'`) when the receiver identifier
357
+ * is a known user-defined namespace binding AND — when the caller provides a
358
+ * {@link UserNamespaceContext} with `safeMemberStarts` — the member access
359
+ * site is in that set (i.e. the receiver is not shadowed by any enclosing
360
+ * scope). Otherwise returns `null`.
361
+ *
362
+ * The property name is taken as the entity id verbatim — we cannot statically
363
+ * resolve what `entities.user` binds to without reading the other file, so we
364
+ * treat the member name as the candidate target and let
365
+ * {@link deriveEntityIdentifierName}'s downstream `knownEntityIds` check
366
+ * report a missing target.
367
+ */
368
+ export const isUserNamespaceReceiverAllowed = (
369
+ receiver: string,
370
+ memberStart: number,
371
+ ctx: UserNamespaceContext
372
+ ): boolean => {
373
+ if (!ctx.bindings.has(receiver)) {
374
+ return false;
375
+ }
376
+ // Scope-aware gate: when the pre-pass produced a set, the member access
377
+ // must appear in it. Without the set, fall back to the bare name check.
378
+ return ctx.safeMemberStarts ? ctx.safeMemberStarts.has(memberStart) : true;
379
+ };
380
+
381
+ const getEntityReferenceTargetFromNamespaceMember = (
382
+ member: {
383
+ readonly object?: AstNode;
384
+ readonly property?: AstNode;
385
+ readonly start: number;
386
+ },
387
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
388
+ ): string | null => {
389
+ const ctx = asUserNamespaceContext(userNamespace);
390
+ if (!ctx || ctx.bindings.size === 0) {
391
+ return null;
392
+ }
393
+ const receiver = member.object ? identifierName(member.object) : null;
394
+ if (
395
+ !receiver ||
396
+ !isUserNamespaceReceiverAllowed(receiver, member.start, ctx)
397
+ ) {
398
+ return null;
399
+ }
400
+ const { property } = member;
401
+ if (!property || property.type !== 'Identifier') {
402
+ return null;
403
+ }
404
+ return identifierName(property);
405
+ };
406
+
407
+ const getEntityReferenceTargetFromObject = (
408
+ object: AstNode,
409
+ namedEntityIds: ReadonlyMap<string, string>,
410
+ knownEntityIds?: ReadonlySet<string>,
411
+ importAliases?: ReadonlyMap<string, string>,
412
+ context?: ReadonlySet<string> | FrameworkNamespaceContext,
413
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
414
+ ): string | null => {
415
+ if (object.type === 'Identifier') {
416
+ const bindingName = identifierName(object);
417
+ return bindingName
418
+ ? deriveEntityIdentifierName(
419
+ bindingName,
420
+ namedEntityIds,
421
+ knownEntityIds,
422
+ importAliases
423
+ )
424
+ : null;
425
+ }
426
+
427
+ const member = getEntityReferenceMember(object);
428
+ if (member) {
429
+ const namespaceTarget = getEntityReferenceTargetFromNamespaceMember(
430
+ member,
431
+ userNamespace
432
+ );
433
+ if (namespaceTarget) {
434
+ return namespaceTarget;
435
+ }
436
+ }
437
+
438
+ return extractEntityDefinition(object, context)?.name ?? null;
439
+ };
440
+
441
+ const ENTITY_ID_WRAPPER_METHODS = new Set([
442
+ 'brand',
443
+ 'catch',
444
+ 'default',
445
+ 'describe',
446
+ 'meta',
447
+ 'nullable',
448
+ 'nullish',
449
+ 'optional',
450
+ 'readonly',
451
+ ]);
452
+
453
+ const getEntityIdCallMember = (
454
+ node: AstNode
455
+ ): {
456
+ readonly member: NonNullable<ReturnType<typeof getEntityReferenceMember>>;
457
+ readonly propertyName: string;
458
+ } | null => {
459
+ const callee = node['callee'] as AstNode | undefined;
460
+ const member = callee ? getEntityReferenceMember(callee) : null;
461
+ const propertyName = member ? identifierName(member.property) : null;
462
+ return member && propertyName ? { member, propertyName } : null;
463
+ };
464
+
465
+ const getEntityIdCallObject = function getEntityIdCallObject(
466
+ node: AstNode | undefined
467
+ ): AstNode | null {
468
+ const current = node;
469
+ if (!current || current.type !== 'CallExpression') {
470
+ return null;
471
+ }
472
+
473
+ const member = getEntityIdCallMember(current);
474
+ if (!member) {
475
+ return null;
476
+ }
477
+ if (member.propertyName === 'id') {
478
+ return member.member.object ?? null;
479
+ }
480
+
481
+ return ENTITY_ID_WRAPPER_METHODS.has(member.propertyName)
482
+ ? getEntityIdCallObject(member.member.object)
483
+ : null;
484
+ };
485
+
486
+ const extractEntityReferenceTarget = (
487
+ node: AstNode | undefined,
488
+ namedEntityIds: ReadonlyMap<string, string>,
489
+ knownEntityIds?: ReadonlySet<string>,
490
+ importAliases?: ReadonlyMap<string, string>,
491
+ context?: ReadonlySet<string> | FrameworkNamespaceContext,
492
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
493
+ ): string | null => {
494
+ const object = getEntityIdCallObject(node);
495
+ return object
496
+ ? getEntityReferenceTargetFromObject(
497
+ object,
498
+ namedEntityIds,
499
+ knownEntityIds,
500
+ importAliases,
501
+ context,
502
+ userNamespace
503
+ )
504
+ : null;
505
+ };
506
+
507
+ const getEntityShapeProperties = (
508
+ definition: EntityDefinition
509
+ ): readonly AstNode[] =>
510
+ (definition.shape['properties'] as readonly AstNode[] | undefined) ?? [];
511
+
512
+ const buildEntityReferenceSite = (
513
+ definition: EntityDefinition,
514
+ property: AstNode,
515
+ namedEntityIds: ReadonlyMap<string, string>,
516
+ knownEntityIds?: ReadonlySet<string>,
517
+ importAliases?: ReadonlyMap<string, string>,
518
+ context?: ReadonlySet<string> | FrameworkNamespaceContext,
519
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
520
+ ): EntityReferenceSite | null => {
521
+ if (property.type !== 'Property') {
522
+ return null;
523
+ }
524
+
525
+ const field = getPropertyName(property.key);
526
+ const target = extractEntityReferenceTarget(
527
+ property.value as AstNode | undefined,
528
+ namedEntityIds,
529
+ knownEntityIds,
530
+ importAliases,
531
+ context,
532
+ userNamespace
533
+ );
534
+ if (!field || !target) {
535
+ return null;
536
+ }
537
+
538
+ return {
539
+ field,
540
+ source: definition.name,
541
+ start: property.start,
542
+ target,
543
+ };
544
+ };
545
+
546
+ const findEntityReferenceSitesForDefinition = (
547
+ definition: EntityDefinition,
548
+ namedEntityIds: ReadonlyMap<string, string>,
549
+ knownEntityIds?: ReadonlySet<string>,
550
+ importAliases?: ReadonlyMap<string, string>,
551
+ context?: ReadonlySet<string> | FrameworkNamespaceContext,
552
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
553
+ ): readonly EntityReferenceSite[] =>
554
+ getEntityShapeProperties(definition).flatMap((property) => {
555
+ const reference = buildEntityReferenceSite(
556
+ definition,
557
+ property,
558
+ namedEntityIds,
559
+ knownEntityIds,
560
+ importAliases,
561
+ context,
562
+ userNamespace
563
+ );
564
+ return reference ? [reference] : [];
565
+ });
566
+
567
+ /** Collect all entity field references declared via `.id()` in a parsed file. */
568
+ export const collectEntityReferenceSites = (
569
+ ast: AstNode,
570
+ knownEntityIds?: ReadonlySet<string>
571
+ ): readonly EntityReferenceSite[] => {
572
+ const namedEntityIds = collectNamedEntityIds(ast);
573
+ const importAliases = collectImportAliasMap(ast);
574
+ const userNamespace = buildUserNamespaceContext(ast);
575
+ const context = buildFrameworkNamespaceContext(ast);
576
+ return findEntityDefinitions(ast, context).flatMap((definition) =>
577
+ findEntityReferenceSitesForDefinition(
578
+ definition,
579
+ namedEntityIds,
580
+ knownEntityIds,
581
+ importAliases,
582
+ context,
583
+ userNamespace
584
+ )
585
+ );
586
+ };
587
+
588
+ /** Collect entity reference targets keyed by source entity name. */
589
+ export const collectEntityReferenceTargetsByName = (
590
+ ast: AstNode,
591
+ knownEntityIds?: ReadonlySet<string>
592
+ ): ReadonlyMap<string, readonly string[]> => {
593
+ const targetsByName = new Map<string, Set<string>>();
594
+
595
+ for (const reference of collectEntityReferenceSites(ast, knownEntityIds)) {
596
+ const existing = targetsByName.get(reference.source);
597
+ if (existing) {
598
+ existing.add(reference.target);
599
+ continue;
600
+ }
601
+
602
+ targetsByName.set(reference.source, new Set([reference.target]));
603
+ }
604
+
605
+ return new Map(
606
+ [...targetsByName.entries()].map(([name, targets]) => [name, [...targets]])
607
+ );
608
+ };
609
+
610
+ // ---------------------------------------------------------------------------
611
+ // Implementation body extraction
612
+ // ---------------------------------------------------------------------------
613
+
614
+ /**
615
+ * Extract top-level `implementation:` property values from an ObjectExpression's direct properties.
616
+ *
617
+ * Does not recurse into nested objects, so `meta: { implementation: ... }` is ignored.
618
+ */
@@ -0,0 +1,45 @@
1
+ /** Warden-private pragma helpers. */
2
+
3
+ const WARDEN_IGNORE_NEXT_LINE_PRAGMAS = new Set([
4
+ '// warden-ignore-next-line',
5
+ '<!-- warden-ignore-next-line -->',
6
+ ]);
7
+
8
+ /**
9
+ * Split source code into lines for pragma lookups. Callers should split once
10
+ * per `check` invocation and thread the result through to
11
+ * {@link hasIgnoreCommentOnLine} so we avoid re-splitting the full source on
12
+ * every match in files with many draft-like string literals.
13
+ */
14
+ export const splitSourceLines = (sourceCode: string): readonly string[] =>
15
+ sourceCode.split('\n');
16
+
17
+ /**
18
+ * Check whether the line immediately preceding `line` contains a
19
+ * `warden-ignore-next-line` pragma (leading/trailing whitespace tolerated).
20
+ * Pragma scope is strictly one line — an intervening blank line breaks it.
21
+ *
22
+ * Takes a pre-split `lines` array so callers can split the source once per
23
+ * invocation instead of re-splitting for every literal they check.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * // warden-ignore-next-line
28
+ * const x = '_draft.intentional'; // suppressed
29
+ * ```
30
+ */
31
+ export const hasIgnoreCommentOnLine = (
32
+ lines: readonly string[],
33
+ line: number
34
+ ): boolean => {
35
+ if (line <= 1) {
36
+ return false;
37
+ }
38
+
39
+ const previous = lines[line - 2];
40
+ if (previous === undefined) {
41
+ return false;
42
+ }
43
+
44
+ return WARDEN_IGNORE_NEXT_LINE_PRAGMAS.has(previous.trim());
45
+ };