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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +508 -6
  2. package/README.md +77 -26
  3. package/bin/warden.ts +50 -0
  4. package/package.json +27 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +28 -0
  7. package/src/cli.ts +1374 -103
  8. package/src/command.ts +953 -0
  9. package/src/config.ts +184 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +106 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +206 -14
  16. package/src/project-context.ts +163 -0
  17. package/src/resolve.ts +530 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/ast.ts +3176 -85
  20. package/src/rules/circular-refs.ts +154 -0
  21. package/src/rules/composes-declarations.ts +704 -0
  22. package/src/rules/context-no-surface-types.ts +68 -8
  23. package/src/rules/contour-exists.ts +251 -0
  24. package/src/rules/contour-ids.ts +15 -0
  25. package/src/rules/dead-internal-trail.ts +154 -0
  26. package/src/rules/draft-file-marking.ts +160 -0
  27. package/src/rules/draft-visible-debt.ts +87 -0
  28. package/src/rules/error-mapping-completeness.ts +288 -0
  29. package/src/rules/example-valid.ts +401 -0
  30. package/src/rules/fires-declarations.ts +758 -0
  31. package/src/rules/implementation-returns-result.ts +1265 -95
  32. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  33. package/src/rules/incomplete-crud.ts +580 -0
  34. package/src/rules/index.ts +219 -18
  35. package/src/rules/intent-propagation.ts +127 -0
  36. package/src/rules/layer-field-name-drift.ts +96 -0
  37. package/src/rules/metadata.ts +654 -0
  38. package/src/rules/missing-reconcile.ts +98 -0
  39. package/src/rules/missing-visibility.ts +110 -0
  40. package/src/rules/no-destructured-compose.ts +192 -0
  41. package/src/rules/no-dev-permit-in-source.ts +99 -0
  42. package/src/rules/no-direct-implementation-call.ts +7 -7
  43. package/src/rules/no-legacy-layer-imports.ts +211 -0
  44. package/src/rules/no-native-error-result.ts +111 -0
  45. package/src/rules/no-redundant-result-error-wrap.ts +331 -0
  46. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  47. package/src/rules/no-sync-result-assumption.ts +1134 -99
  48. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  49. package/src/rules/no-throw-in-implementation.ts +10 -9
  50. package/src/rules/no-top-level-surface.ts +389 -0
  51. package/src/rules/on-references-exist.ts +194 -0
  52. package/src/rules/orphaned-signal.ts +150 -0
  53. package/src/rules/owner-projection-parity.ts +146 -0
  54. package/src/rules/permit-governance.ts +25 -0
  55. package/src/rules/public-export-example-coverage.ts +553 -0
  56. package/src/rules/public-internal-deep-imports.ts +517 -0
  57. package/src/rules/public-output-schema.ts +29 -0
  58. package/src/rules/public-union-output-discriminants.ts +150 -0
  59. package/src/rules/read-intent-fires.ts +187 -0
  60. package/src/rules/reference-exists.ts +98 -0
  61. package/src/rules/registry-names.ts +145 -0
  62. package/src/rules/resolved-import-boundary.ts +146 -0
  63. package/src/rules/resource-declarations.ts +704 -0
  64. package/src/rules/resource-exists.ts +179 -0
  65. package/src/rules/resource-id-grammar.ts +65 -0
  66. package/src/rules/resource-mock-coverage.ts +115 -0
  67. package/src/rules/scan.ts +38 -25
  68. package/src/rules/scheduled-destroy-intent.ts +44 -0
  69. package/src/rules/signal-graph-coaching.ts +191 -0
  70. package/src/rules/specs.ts +9 -5
  71. package/src/rules/static-resource-accessor-preference.ts +657 -0
  72. package/src/rules/surface-facet-coherence.ts +370 -0
  73. package/src/rules/trail-versioning-source.ts +1094 -0
  74. package/src/rules/trail-versioning-topo.ts +172 -0
  75. package/src/rules/types.ts +270 -6
  76. package/src/rules/unmaterialized-activation-source.ts +84 -0
  77. package/src/rules/unreachable-detour-shadowing.ts +344 -0
  78. package/src/rules/valid-describe-refs.ts +160 -32
  79. package/src/rules/valid-detour-contract.ts +78 -0
  80. package/src/rules/warden-export-symmetry.ts +533 -0
  81. package/src/rules/warden-rules-use-ast.ts +996 -0
  82. package/src/rules/webhook-route-collision.ts +243 -0
  83. package/src/trails/activation-orphan.trail.ts +84 -0
  84. package/src/trails/circular-refs.trail.ts +29 -0
  85. package/src/trails/composes-declarations.trail.ts +22 -0
  86. package/src/trails/context-no-surface-types.trail.ts +21 -0
  87. package/src/trails/contour-exists.trail.ts +21 -0
  88. package/src/trails/dead-internal-trail.trail.ts +26 -0
  89. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  90. package/src/trails/draft-file-marking.trail.ts +16 -0
  91. package/src/trails/draft-visible-debt.trail.ts +16 -0
  92. package/src/trails/error-mapping-completeness.trail.ts +29 -0
  93. package/src/trails/example-valid.trail.ts +25 -0
  94. package/src/trails/fires-declarations.trail.ts +23 -0
  95. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  96. package/src/trails/implementation-returns-result.trail.ts +20 -0
  97. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  98. package/src/trails/incomplete-crud.trail.ts +39 -0
  99. package/src/trails/index.ts +78 -0
  100. package/src/trails/intent-propagation.trail.ts +30 -0
  101. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  102. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  103. package/src/trails/missing-reconcile.trail.ts +33 -0
  104. package/src/trails/missing-visibility.trail.ts +22 -0
  105. package/src/trails/no-destructured-compose.trail.ts +44 -0
  106. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  107. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  108. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  109. package/src/trails/no-native-error-result.trail.ts +18 -0
  110. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  111. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  112. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  113. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  114. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  115. package/src/trails/no-top-level-surface.trail.ts +43 -0
  116. package/src/trails/on-references-exist.trail.ts +21 -0
  117. package/src/trails/orphaned-signal.trail.ts +36 -0
  118. package/src/trails/owner-projection-parity.trail.ts +26 -0
  119. package/src/trails/pending-force.trail.ts +21 -0
  120. package/src/trails/permit-governance.trail.ts +51 -0
  121. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  122. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  123. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  124. package/src/trails/public-output-schema.trail.ts +55 -0
  125. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  126. package/src/trails/read-intent-fires.trail.ts +20 -0
  127. package/src/trails/reference-exists.trail.ts +25 -0
  128. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  129. package/src/trails/resource-declarations.trail.ts +25 -0
  130. package/src/trails/resource-exists.trail.ts +27 -0
  131. package/src/trails/resource-id-grammar.trail.ts +39 -0
  132. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  133. package/src/trails/run.ts +162 -0
  134. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  135. package/src/trails/schema.ts +194 -0
  136. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  137. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  138. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  139. package/src/trails/topo.ts +6 -0
  140. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  141. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  142. package/src/trails/valid-describe-refs.trail.ts +18 -0
  143. package/src/trails/valid-detour-contract.trail.ts +71 -0
  144. package/src/trails/version-gap.trail.ts +35 -0
  145. package/src/trails/version-pinned-compose.trail.ts +23 -0
  146. package/src/trails/version-without-examples.trail.ts +38 -0
  147. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  148. package/src/trails/warden-rules-use-ast.trail.ts +45 -0
  149. package/src/trails/webhook-route-collision.trail.ts +50 -0
  150. package/src/trails/wrap-rule.ts +213 -0
  151. package/src/workspaces.ts +238 -0
  152. package/.turbo/turbo-build.log +0 -1
  153. package/.turbo/turbo-lint.log +0 -3
  154. package/.turbo/turbo-typecheck.log +0 -1
  155. package/dist/cli.d.ts +0 -46
  156. package/dist/cli.d.ts.map +0 -1
  157. package/dist/cli.js +0 -221
  158. package/dist/cli.js.map +0 -1
  159. package/dist/drift.d.ts +0 -26
  160. package/dist/drift.d.ts.map +0 -1
  161. package/dist/drift.js +0 -27
  162. package/dist/drift.js.map +0 -1
  163. package/dist/formatters.d.ts +0 -29
  164. package/dist/formatters.d.ts.map +0 -1
  165. package/dist/formatters.js +0 -87
  166. package/dist/formatters.js.map +0 -1
  167. package/dist/index.d.ts +0 -26
  168. package/dist/index.d.ts.map +0 -1
  169. package/dist/index.js +0 -26
  170. package/dist/index.js.map +0 -1
  171. package/dist/rules/ast.d.ts +0 -41
  172. package/dist/rules/ast.d.ts.map +0 -1
  173. package/dist/rules/ast.js +0 -163
  174. package/dist/rules/ast.js.map +0 -1
  175. package/dist/rules/context-no-surface-types.d.ts +0 -12
  176. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  177. package/dist/rules/context-no-surface-types.js +0 -96
  178. package/dist/rules/context-no-surface-types.js.map +0 -1
  179. package/dist/rules/implementation-returns-result.d.ts +0 -13
  180. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  181. package/dist/rules/implementation-returns-result.js +0 -231
  182. package/dist/rules/implementation-returns-result.js.map +0 -1
  183. package/dist/rules/index.d.ts +0 -22
  184. package/dist/rules/index.d.ts.map +0 -1
  185. package/dist/rules/index.js +0 -41
  186. package/dist/rules/index.js.map +0 -1
  187. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  188. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  189. package/dist/rules/no-direct-impl-in-route.js +0 -46
  190. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  191. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  192. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  193. package/dist/rules/no-direct-implementation-call.js +0 -39
  194. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  195. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  196. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  197. package/dist/rules/no-sync-result-assumption.js +0 -98
  198. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  199. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  200. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  201. package/dist/rules/no-throw-in-detour-target.js +0 -87
  202. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  203. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  204. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  205. package/dist/rules/no-throw-in-implementation.js +0 -34
  206. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  207. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  208. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  209. package/dist/rules/prefer-schema-inference.js +0 -86
  210. package/dist/rules/prefer-schema-inference.js.map +0 -1
  211. package/dist/rules/scan.d.ts +0 -8
  212. package/dist/rules/scan.d.ts.map +0 -1
  213. package/dist/rules/scan.js +0 -32
  214. package/dist/rules/scan.js.map +0 -1
  215. package/dist/rules/specs.d.ts +0 -29
  216. package/dist/rules/specs.d.ts.map +0 -1
  217. package/dist/rules/specs.js +0 -192
  218. package/dist/rules/specs.js.map +0 -1
  219. package/dist/rules/structure.d.ts +0 -13
  220. package/dist/rules/structure.d.ts.map +0 -1
  221. package/dist/rules/structure.js +0 -142
  222. package/dist/rules/structure.js.map +0 -1
  223. package/dist/rules/types.d.ts +0 -52
  224. package/dist/rules/types.d.ts.map +0 -1
  225. package/dist/rules/types.js +0 -2
  226. package/dist/rules/types.js.map +0 -1
  227. package/dist/rules/valid-describe-refs.d.ts +0 -7
  228. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  229. package/dist/rules/valid-describe-refs.js +0 -51
  230. package/dist/rules/valid-describe-refs.js.map +0 -1
  231. package/dist/rules/valid-detour-refs.d.ts +0 -6
  232. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  233. package/dist/rules/valid-detour-refs.js +0 -116
  234. package/dist/rules/valid-detour-refs.js.map +0 -1
  235. package/src/__tests__/cli.test.ts +0 -198
  236. package/src/__tests__/drift.test.ts +0 -74
  237. package/src/__tests__/formatters.test.ts +0 -157
  238. package/src/__tests__/implementation-returns-result.test.ts +0 -75
  239. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  240. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  241. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  242. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  243. package/src/__tests__/rules.test.ts +0 -188
  244. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  245. package/src/rules/no-direct-impl-in-route.ts +0 -77
  246. package/src/rules/no-throw-in-detour-target.ts +0 -150
  247. package/src/rules/valid-detour-refs.ts +0 -187
  248. package/tsconfig.json +0 -9
  249. package/tsconfig.tsbuildinfo +0 -1
package/src/resolve.ts ADDED
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Public Warden resolver helper surface.
3
+ *
4
+ * These helpers wrap `oxc-resolver` behind Warden-owned import-resolution
5
+ * facts so rules never depend on resolver binding internals directly.
6
+ */
7
+
8
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
9
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
10
+
11
+ import { ResolverFactory } from 'oxc-resolver';
12
+ import type { NapiResolveOptions, ResolveResult } from 'oxc-resolver';
13
+
14
+ import {
15
+ getStringValue,
16
+ isStringLiteral,
17
+ offsetToLine,
18
+ parse,
19
+ walk,
20
+ } from './rules/ast.js';
21
+ import type { AstNode } from './rules/ast.js';
22
+
23
+ export const wardenImportResolutionErrorKinds = [
24
+ 'builtin',
25
+ 'ignored',
26
+ 'not-found',
27
+ 'package-path-not-exported',
28
+ 'other',
29
+ ] as const;
30
+
31
+ export type WardenImportResolutionErrorKind =
32
+ (typeof wardenImportResolutionErrorKinds)[number];
33
+
34
+ export interface WardenImportResolution {
35
+ readonly importerPath: string;
36
+ readonly importSource: string;
37
+ readonly line: number;
38
+ readonly resolvedPath?: string | undefined;
39
+ readonly packageName?: string | undefined;
40
+ readonly packageRoot?: string | undefined;
41
+ readonly crossesPackageBoundary: boolean;
42
+ /**
43
+ * True when a bare package specifier resolves successfully while the target
44
+ * package declares an exports map. This is a coarse resolver fact; it does
45
+ * not prove the resolved file matched a specific export entry.
46
+ */
47
+ readonly usesPublicExport: boolean;
48
+ /**
49
+ * True when a resolved file lands inside an internal/private package path.
50
+ * Export-map-blocked internal specifiers do not have a resolved file path;
51
+ * combine this with errorKind/importSource checks when guarding specifiers.
52
+ */
53
+ readonly isInternalTarget: boolean;
54
+ readonly errorKind?: WardenImportResolutionErrorKind | undefined;
55
+ readonly errorMessage?: string | undefined;
56
+ readonly builtinModule?: string | undefined;
57
+ }
58
+
59
+ export interface WardenImportSpecifier {
60
+ readonly importSource: string;
61
+ readonly line: number;
62
+ }
63
+
64
+ export interface WardenResolverOptions {
65
+ readonly rootDir?: string | undefined;
66
+ readonly resolveOptions?: NapiResolveOptions | undefined;
67
+ }
68
+
69
+ export interface WardenProjectResolver {
70
+ readonly rootDir: string;
71
+ readonly resolveOptions: NapiResolveOptions;
72
+ clearCache(): void;
73
+ resolveImport(
74
+ importerPath: string,
75
+ importSource: string,
76
+ line?: number
77
+ ): WardenImportResolution;
78
+ }
79
+
80
+ interface PackageInfo {
81
+ readonly name?: string | undefined;
82
+ readonly packageJsonPath: string;
83
+ readonly root: string;
84
+ readonly exports?: unknown;
85
+ }
86
+
87
+ const conditionNames = ['bun', 'node', 'import', 'default'] as const;
88
+ export const packagePathNotExportedErrorFragment =
89
+ 'is not exported under the conditions';
90
+
91
+ export const defaultWardenResolveOptions = {
92
+ builtinModules: true,
93
+ conditionNames: [...conditionNames],
94
+ extensionAlias: {
95
+ '.cjs': ['.cts', '.cjs'],
96
+ '.js': ['.ts', '.tsx', '.js'],
97
+ '.mjs': ['.mts', '.mjs'],
98
+ },
99
+ extensions: [
100
+ '.ts',
101
+ '.tsx',
102
+ '.mts',
103
+ '.cts',
104
+ '.js',
105
+ '.jsx',
106
+ '.mjs',
107
+ '.cjs',
108
+ '.json',
109
+ ],
110
+ moduleType: true,
111
+ symlinks: true,
112
+ tsconfig: 'auto',
113
+ } satisfies NapiResolveOptions;
114
+
115
+ export const normalizePath = (path: string): string =>
116
+ path.replaceAll('\\', '/');
117
+
118
+ const mergeStringLists = (
119
+ base: readonly string[] = [],
120
+ override: readonly string[] = []
121
+ ): string[] => [...new Set([...base, ...override])];
122
+
123
+ const mergeExtensionAlias = (
124
+ base: NonNullable<NapiResolveOptions['extensionAlias']>,
125
+ override: NapiResolveOptions['extensionAlias'] | undefined
126
+ ): NonNullable<NapiResolveOptions['extensionAlias']> => {
127
+ const merged: Record<string, string[]> = Object.fromEntries(
128
+ Object.entries(base).map(([extension, aliases]) => [
129
+ extension,
130
+ [...aliases],
131
+ ])
132
+ );
133
+
134
+ for (const [extension, aliases] of Object.entries(override ?? {})) {
135
+ merged[extension] = mergeStringLists(merged[extension], aliases);
136
+ }
137
+
138
+ return merged;
139
+ };
140
+
141
+ const mergeWardenResolveOptions = (
142
+ overrides: NapiResolveOptions | undefined
143
+ ): NapiResolveOptions => ({
144
+ ...defaultWardenResolveOptions,
145
+ ...overrides,
146
+ conditionNames: mergeStringLists(
147
+ defaultWardenResolveOptions.conditionNames,
148
+ overrides?.conditionNames
149
+ ),
150
+ extensionAlias: mergeExtensionAlias(
151
+ defaultWardenResolveOptions.extensionAlias,
152
+ overrides?.extensionAlias
153
+ ),
154
+ extensions: mergeStringLists(
155
+ defaultWardenResolveOptions.extensions,
156
+ overrides?.extensions
157
+ ),
158
+ });
159
+
160
+ const normalizeRealPath = (path: string): string => {
161
+ try {
162
+ return normalizePath(realpathSync(path));
163
+ } catch {
164
+ return normalizePath(resolve(path));
165
+ }
166
+ };
167
+
168
+ const readPackageJson = (
169
+ packageJsonPath: string
170
+ ): Record<string, unknown> | null => {
171
+ try {
172
+ return JSON.parse(readFileSync(packageJsonPath, 'utf8')) as Record<
173
+ string,
174
+ unknown
175
+ >;
176
+ } catch {
177
+ return null;
178
+ }
179
+ };
180
+
181
+ const packageInfoFromPackageJson = (
182
+ packageJsonPath: string
183
+ ): PackageInfo | null => {
184
+ if (!existsSync(packageJsonPath)) {
185
+ return null;
186
+ }
187
+ const json = readPackageJson(packageJsonPath);
188
+ if (!json) {
189
+ return null;
190
+ }
191
+ const root = normalizeRealPath(dirname(packageJsonPath));
192
+ return {
193
+ ...(json['exports'] === undefined ? {} : { exports: json['exports'] }),
194
+ ...(typeof json['name'] === 'string' ? { name: json['name'] } : {}),
195
+ packageJsonPath: normalizeRealPath(packageJsonPath),
196
+ root,
197
+ };
198
+ };
199
+
200
+ const findNearestPackageJson = (fromPath: string): string | null => {
201
+ let dir = dirname(resolve(fromPath));
202
+ while (true) {
203
+ const packageJsonPath = join(dir, 'package.json');
204
+ if (existsSync(packageJsonPath)) {
205
+ return packageJsonPath;
206
+ }
207
+ const parent = dirname(dir);
208
+ if (parent === dir) {
209
+ return null;
210
+ }
211
+ dir = parent;
212
+ }
213
+ };
214
+
215
+ const findPackageInfoForPath = (path: string): PackageInfo | null => {
216
+ const packageJsonPath = findNearestPackageJson(path);
217
+ return packageJsonPath ? packageInfoFromPackageJson(packageJsonPath) : null;
218
+ };
219
+
220
+ const parseBarePackageName = (specifier: string): string | null => {
221
+ if (
222
+ specifier.startsWith('.') ||
223
+ specifier.startsWith('/') ||
224
+ specifier.startsWith('#') ||
225
+ specifier.startsWith('node:') ||
226
+ /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier)
227
+ ) {
228
+ return null;
229
+ }
230
+
231
+ const parts = specifier.split('/');
232
+ if (specifier.startsWith('@')) {
233
+ const [scope, name] = parts;
234
+ return scope && name ? `${scope}/${name}` : null;
235
+ }
236
+ return parts[0] ?? null;
237
+ };
238
+
239
+ const packageSpecifierParts = (
240
+ specifier: string
241
+ ): { readonly packageName: string; readonly subpath: string } | null => {
242
+ const packageName = parseBarePackageName(specifier);
243
+ if (!packageName) {
244
+ return null;
245
+ }
246
+ const suffix = specifier.slice(packageName.length);
247
+ return {
248
+ packageName,
249
+ subpath: suffix.length === 0 ? '.' : `.${suffix}`,
250
+ };
251
+ };
252
+
253
+ const findNodeModulesPackageInfo = (
254
+ importerPath: string,
255
+ packageName: string
256
+ ): PackageInfo | null => {
257
+ const packageSegments = packageName.split('/');
258
+ let dir = dirname(resolve(importerPath));
259
+ while (true) {
260
+ const packageJsonPath = join(
261
+ dir,
262
+ 'node_modules',
263
+ ...packageSegments,
264
+ 'package.json'
265
+ );
266
+ const info = packageInfoFromPackageJson(packageJsonPath);
267
+ if (info) {
268
+ return info;
269
+ }
270
+ const parent = dirname(dir);
271
+ if (parent === dir) {
272
+ return null;
273
+ }
274
+ dir = parent;
275
+ }
276
+ };
277
+
278
+ const hasExportsMap = (info: PackageInfo | null): boolean =>
279
+ info?.exports !== undefined;
280
+
281
+ const classifyResolverError = (
282
+ error: string
283
+ ): WardenImportResolutionErrorKind => {
284
+ if (error.includes(packagePathNotExportedErrorFragment)) {
285
+ return 'package-path-not-exported';
286
+ }
287
+ if (error.includes('ignored')) {
288
+ return 'ignored';
289
+ }
290
+ if (error.includes('not found') || error.includes('Cannot find')) {
291
+ return 'not-found';
292
+ }
293
+ return 'other';
294
+ };
295
+
296
+ const isInternalTargetPath = (
297
+ packageRoot: string | undefined,
298
+ resolvedPath: string | undefined
299
+ ): boolean => {
300
+ if (!packageRoot || !resolvedPath) {
301
+ return false;
302
+ }
303
+ const relativePath = normalizePath(relative(packageRoot, resolvedPath));
304
+ return /(?:^|\/)(?:src\/)?(?:internal|private|_internal|_private)(?:\/|$)/.test(
305
+ relativePath
306
+ );
307
+ };
308
+
309
+ const resolveResultPackageInfo = (
310
+ result: ResolveResult
311
+ ): PackageInfo | null => {
312
+ if (result.packageJsonPath) {
313
+ return packageInfoFromPackageJson(result.packageJsonPath);
314
+ }
315
+ if (result.path) {
316
+ return findPackageInfoForPath(result.path);
317
+ }
318
+ return null;
319
+ };
320
+
321
+ const resolveErrorKind = (
322
+ errorMessage: string | undefined,
323
+ builtinModule: string | undefined
324
+ ): WardenImportResolutionErrorKind | undefined => {
325
+ if (errorMessage) {
326
+ return classifyResolverError(errorMessage);
327
+ }
328
+ return builtinModule ? 'builtin' : undefined;
329
+ };
330
+
331
+ const optionalResolutionFields = ({
332
+ builtinModule,
333
+ errorKind,
334
+ errorMessage,
335
+ packageName,
336
+ packageRoot,
337
+ resolvedPath,
338
+ }: {
339
+ readonly builtinModule: string | undefined;
340
+ readonly errorKind: WardenImportResolutionErrorKind | undefined;
341
+ readonly errorMessage: string | undefined;
342
+ readonly packageName: string | undefined;
343
+ readonly packageRoot: string | undefined;
344
+ readonly resolvedPath: string | undefined;
345
+ }): Partial<WardenImportResolution> => ({
346
+ ...(builtinModule ? { builtinModule } : {}),
347
+ ...(errorKind ? { errorKind } : {}),
348
+ ...(errorMessage ? { errorMessage } : {}),
349
+ ...(packageName ? { packageName } : {}),
350
+ ...(packageRoot ? { packageRoot } : {}),
351
+ ...(resolvedPath ? { resolvedPath } : {}),
352
+ });
353
+
354
+ const buildResolution = ({
355
+ importSource,
356
+ importerPath,
357
+ line,
358
+ result,
359
+ }: {
360
+ readonly importSource: string;
361
+ readonly importerPath: string;
362
+ readonly line: number;
363
+ readonly result: ResolveResult;
364
+ }): WardenImportResolution => {
365
+ const normalizedImporter = normalizeRealPath(importerPath);
366
+ const importerInfo = findPackageInfoForPath(normalizedImporter);
367
+ const specifier = packageSpecifierParts(importSource);
368
+ const resolvedInfo = resolveResultPackageInfo(result);
369
+ const packageInfo =
370
+ resolvedInfo ??
371
+ (specifier
372
+ ? findNodeModulesPackageInfo(normalizedImporter, specifier.packageName)
373
+ : null);
374
+ const resolvedPath = result.path ? normalizeRealPath(result.path) : undefined;
375
+ const packageRoot = packageInfo?.root;
376
+ const packageName = packageInfo?.name ?? specifier?.packageName;
377
+ const errorMessage = result.error;
378
+ const builtinModule = result.builtin?.resolved;
379
+ const errorKind = resolveErrorKind(errorMessage, builtinModule);
380
+ const crossesPackageBoundary = Boolean(
381
+ importerInfo?.root && packageRoot && importerInfo.root !== packageRoot
382
+ );
383
+ const usesPublicExport = Boolean(
384
+ !errorMessage && specifier && hasExportsMap(packageInfo)
385
+ );
386
+ const isInternalTarget = isInternalTargetPath(packageRoot, resolvedPath);
387
+
388
+ return {
389
+ crossesPackageBoundary,
390
+ importSource,
391
+ importerPath: normalizedImporter,
392
+ isInternalTarget,
393
+ line,
394
+ usesPublicExport,
395
+ ...optionalResolutionFields({
396
+ builtinModule,
397
+ errorKind,
398
+ errorMessage,
399
+ packageName,
400
+ packageRoot,
401
+ resolvedPath,
402
+ }),
403
+ };
404
+ };
405
+
406
+ const getModuleSourceNode = (node: AstNode): AstNode | undefined =>
407
+ (node as unknown as { source?: AstNode }).source;
408
+
409
+ const isStaticImportNode = (node: AstNode): boolean =>
410
+ node.type === 'ImportDeclaration' ||
411
+ node.type === 'ExportNamedDeclaration' ||
412
+ node.type === 'ExportAllDeclaration';
413
+
414
+ const isDynamicImportExpression = (node: AstNode): boolean =>
415
+ node.type === 'ImportExpression';
416
+
417
+ const isTypeImportNode = (node: AstNode): boolean =>
418
+ node.type === 'TSImportType';
419
+
420
+ const isRequireCallExpression = (node: AstNode): boolean => {
421
+ if (node.type !== 'CallExpression') {
422
+ return false;
423
+ }
424
+ const { callee } = node as unknown as { callee?: AstNode };
425
+ return (
426
+ callee?.type === 'Identifier' &&
427
+ (callee as unknown as { name?: string }).name === 'require'
428
+ );
429
+ };
430
+
431
+ const getRequireSourceNode = (node: AstNode): AstNode | undefined =>
432
+ (node as unknown as { arguments?: readonly AstNode[] }).arguments?.[0];
433
+
434
+ export const collectImportSpecifiers = (
435
+ filePath: string,
436
+ sourceCode: string
437
+ ): readonly WardenImportSpecifier[] => {
438
+ const ast = parse(filePath, sourceCode);
439
+ if (!ast) {
440
+ return [];
441
+ }
442
+
443
+ const specifiers: WardenImportSpecifier[] = [];
444
+ walk(ast, (node) => {
445
+ if (
446
+ !isStaticImportNode(node) &&
447
+ !isDynamicImportExpression(node) &&
448
+ !isTypeImportNode(node) &&
449
+ !isRequireCallExpression(node)
450
+ ) {
451
+ return;
452
+ }
453
+ const source = isRequireCallExpression(node)
454
+ ? getRequireSourceNode(node)
455
+ : getModuleSourceNode(node);
456
+ const importSource =
457
+ source && isStringLiteral(source) ? getStringValue(source) : null;
458
+ if (!importSource) {
459
+ return;
460
+ }
461
+ specifiers.push({
462
+ importSource,
463
+ line: offsetToLine(sourceCode, node.start),
464
+ });
465
+ });
466
+ return specifiers;
467
+ };
468
+
469
+ export const createWardenResolver = (
470
+ options: WardenResolverOptions = {}
471
+ ): WardenProjectResolver => {
472
+ const rootDir = normalizeRealPath(options.rootDir ?? process.cwd());
473
+ const resolveOptions = mergeWardenResolveOptions(options.resolveOptions);
474
+ const resolver = new ResolverFactory(resolveOptions);
475
+ const cache = new Map<string, ResolveResult>();
476
+
477
+ return {
478
+ clearCache: () => {
479
+ cache.clear();
480
+ resolver.clearCache();
481
+ },
482
+ resolveImport: (
483
+ importerPath: string,
484
+ importSource: string,
485
+ line = 1
486
+ ): WardenImportResolution => {
487
+ const absoluteImporterPath = isAbsolute(importerPath)
488
+ ? importerPath
489
+ : resolve(rootDir, importerPath);
490
+ const normalizedImporterPath = normalizeRealPath(absoluteImporterPath);
491
+ const key = `${normalizedImporterPath}\0${importSource}`;
492
+ const cached = cache.get(key);
493
+ if (cached) {
494
+ return buildResolution({
495
+ importSource,
496
+ importerPath: normalizedImporterPath,
497
+ line,
498
+ result: cached,
499
+ });
500
+ }
501
+ const result = resolver.resolveFileSync(
502
+ normalizedImporterPath,
503
+ importSource
504
+ );
505
+ const resolution = buildResolution({
506
+ importSource,
507
+ importerPath: normalizedImporterPath,
508
+ line,
509
+ result,
510
+ });
511
+ cache.set(key, result);
512
+ return resolution;
513
+ },
514
+ resolveOptions,
515
+ rootDir,
516
+ };
517
+ };
518
+
519
+ export const collectImportResolutionsForFile = ({
520
+ filePath,
521
+ resolver,
522
+ sourceCode,
523
+ }: {
524
+ readonly filePath: string;
525
+ readonly resolver: WardenProjectResolver;
526
+ readonly sourceCode: string;
527
+ }): readonly WardenImportResolution[] =>
528
+ collectImportSpecifiers(filePath, sourceCode).map((specifier) =>
529
+ resolver.resolveImport(filePath, specifier.importSource, specifier.line)
530
+ );
@@ -0,0 +1,97 @@
1
+ import type { Topo } from '@ontrails/core';
2
+
3
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
4
+
5
+ const RULE_NAME = 'activation-orphan';
6
+ const TOPO_FILE = '<topo>';
7
+ const DRAFT_ID_PREFIX = ['_draft', '.'].join('');
8
+
9
+ const isDraftSourceId = (id: string): boolean => id.startsWith(DRAFT_ID_PREFIX);
10
+
11
+ const sortedUnique = (values: Iterable<string>): readonly string[] =>
12
+ [...new Set(values)].toSorted();
13
+
14
+ const collectSignalProducerIds = (topo: Topo): ReadonlySet<string> => {
15
+ const producerIds = new Set<string>();
16
+
17
+ for (const signal of topo.listSignals()) {
18
+ if ((signal.from?.length ?? 0) > 0) {
19
+ producerIds.add(signal.id);
20
+ }
21
+ }
22
+
23
+ for (const resource of topo.resources.values()) {
24
+ for (const signal of resource.signals ?? []) {
25
+ producerIds.add(signal.id);
26
+ }
27
+ }
28
+
29
+ for (const trail of topo.list()) {
30
+ for (const signalId of trail.fires) {
31
+ producerIds.add(signalId);
32
+ }
33
+ }
34
+
35
+ return producerIds;
36
+ };
37
+
38
+ const collectKnownSignalIds = (topo: Topo): ReadonlySet<string> =>
39
+ new Set(topo.listSignals().map((signal) => signal.id));
40
+
41
+ const collectSignalConsumers = (
42
+ topo: Topo
43
+ ): ReadonlyMap<string, readonly string[]> => {
44
+ const consumersBySignal = new Map<string, Set<string>>();
45
+
46
+ for (const trail of topo.list()) {
47
+ for (const activation of trail.activationSources) {
48
+ if (activation.source.kind !== 'signal') {
49
+ continue;
50
+ }
51
+ const consumers =
52
+ consumersBySignal.get(activation.source.id) ?? new Set<string>();
53
+ consumers.add(trail.id);
54
+ consumersBySignal.set(activation.source.id, consumers);
55
+ }
56
+ }
57
+
58
+ return new Map(
59
+ [...consumersBySignal.entries()].map(([signalId, consumers]) => [
60
+ signalId,
61
+ sortedUnique(consumers),
62
+ ])
63
+ );
64
+ };
65
+
66
+ const buildDiagnostic = (
67
+ signalId: string,
68
+ consumerIds: readonly string[]
69
+ ): WardenDiagnostic => ({
70
+ filePath: TOPO_FILE,
71
+ line: 1,
72
+ message: `Signal activation source "${signalId}" activates trail${consumerIds.length === 1 ? '' : 's'} ${consumerIds.map((id) => `"${id}"`).join(', ')} but has no producer declaration in the topo. Add a trail fires: declaration, add signal from: producer metadata, or remove the unused activation source.`,
73
+ rule: RULE_NAME,
74
+ severity: 'warn',
75
+ });
76
+
77
+ export const activationOrphan: TopoAwareWardenRule = {
78
+ checkTopo: (topo) => {
79
+ const knownSignalIds = collectKnownSignalIds(topo);
80
+ const producerIds = collectSignalProducerIds(topo);
81
+ const consumersBySignal = collectSignalConsumers(topo);
82
+
83
+ return [...consumersBySignal.entries()]
84
+ .filter(
85
+ ([signalId]) =>
86
+ !isDraftSourceId(signalId) &&
87
+ knownSignalIds.has(signalId) &&
88
+ !producerIds.has(signalId)
89
+ )
90
+ .toSorted(([a], [b]) => a.localeCompare(b))
91
+ .map(([signalId, consumerIds]) => buildDiagnostic(signalId, consumerIds));
92
+ },
93
+ description:
94
+ 'Warn when signal activation consumers reference sources with no producer declaration in the topo.',
95
+ name: RULE_NAME,
96
+ severity: 'warn',
97
+ };