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

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 +497 -6
  2. package/README.md +77 -26
  3. package/bin/warden.ts +50 -0
  4. package/package.json +27 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +28 -0
  7. package/src/cli.ts +1374 -103
  8. package/src/command.ts +953 -0
  9. package/src/config.ts +184 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +106 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +206 -14
  16. package/src/project-context.ts +163 -0
  17. package/src/resolve.ts +530 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/ast.ts +3176 -85
  20. package/src/rules/circular-refs.ts +154 -0
  21. package/src/rules/composes-declarations.ts +704 -0
  22. package/src/rules/context-no-surface-types.ts +68 -8
  23. package/src/rules/contour-exists.ts +251 -0
  24. package/src/rules/contour-ids.ts +15 -0
  25. package/src/rules/dead-internal-trail.ts +154 -0
  26. package/src/rules/draft-file-marking.ts +160 -0
  27. package/src/rules/draft-visible-debt.ts +87 -0
  28. package/src/rules/error-mapping-completeness.ts +288 -0
  29. package/src/rules/example-valid.ts +401 -0
  30. package/src/rules/fires-declarations.ts +758 -0
  31. package/src/rules/implementation-returns-result.ts +1265 -95
  32. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  33. package/src/rules/incomplete-crud.ts +580 -0
  34. package/src/rules/index.ts +219 -18
  35. package/src/rules/intent-propagation.ts +127 -0
  36. package/src/rules/layer-field-name-drift.ts +96 -0
  37. package/src/rules/metadata.ts +654 -0
  38. package/src/rules/missing-reconcile.ts +98 -0
  39. package/src/rules/missing-visibility.ts +110 -0
  40. package/src/rules/no-destructured-compose.ts +192 -0
  41. package/src/rules/no-dev-permit-in-source.ts +99 -0
  42. package/src/rules/no-direct-implementation-call.ts +7 -7
  43. package/src/rules/no-legacy-layer-imports.ts +211 -0
  44. package/src/rules/no-native-error-result.ts +111 -0
  45. package/src/rules/no-redundant-result-error-wrap.ts +331 -0
  46. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  47. package/src/rules/no-sync-result-assumption.ts +1134 -99
  48. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  49. package/src/rules/no-throw-in-implementation.ts +10 -9
  50. package/src/rules/no-top-level-surface.ts +389 -0
  51. package/src/rules/on-references-exist.ts +194 -0
  52. package/src/rules/orphaned-signal.ts +150 -0
  53. package/src/rules/owner-projection-parity.ts +146 -0
  54. package/src/rules/permit-governance.ts +25 -0
  55. package/src/rules/public-export-example-coverage.ts +553 -0
  56. package/src/rules/public-internal-deep-imports.ts +517 -0
  57. package/src/rules/public-output-schema.ts +29 -0
  58. package/src/rules/public-union-output-discriminants.ts +150 -0
  59. package/src/rules/read-intent-fires.ts +187 -0
  60. package/src/rules/reference-exists.ts +98 -0
  61. package/src/rules/registry-names.ts +145 -0
  62. package/src/rules/resolved-import-boundary.ts +146 -0
  63. package/src/rules/resource-declarations.ts +704 -0
  64. package/src/rules/resource-exists.ts +179 -0
  65. package/src/rules/resource-id-grammar.ts +65 -0
  66. package/src/rules/resource-mock-coverage.ts +115 -0
  67. package/src/rules/scan.ts +38 -25
  68. package/src/rules/scheduled-destroy-intent.ts +44 -0
  69. package/src/rules/signal-graph-coaching.ts +191 -0
  70. package/src/rules/specs.ts +9 -5
  71. package/src/rules/static-resource-accessor-preference.ts +657 -0
  72. package/src/rules/surface-facet-coherence.ts +370 -0
  73. package/src/rules/trail-versioning-source.ts +1094 -0
  74. package/src/rules/trail-versioning-topo.ts +172 -0
  75. package/src/rules/types.ts +270 -6
  76. package/src/rules/unmaterialized-activation-source.ts +84 -0
  77. package/src/rules/unreachable-detour-shadowing.ts +344 -0
  78. package/src/rules/valid-describe-refs.ts +160 -32
  79. package/src/rules/valid-detour-contract.ts +78 -0
  80. package/src/rules/warden-export-symmetry.ts +533 -0
  81. package/src/rules/warden-rules-use-ast.ts +996 -0
  82. package/src/rules/webhook-route-collision.ts +243 -0
  83. package/src/trails/activation-orphan.trail.ts +84 -0
  84. package/src/trails/circular-refs.trail.ts +29 -0
  85. package/src/trails/composes-declarations.trail.ts +22 -0
  86. package/src/trails/context-no-surface-types.trail.ts +21 -0
  87. package/src/trails/contour-exists.trail.ts +21 -0
  88. package/src/trails/dead-internal-trail.trail.ts +26 -0
  89. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  90. package/src/trails/draft-file-marking.trail.ts +16 -0
  91. package/src/trails/draft-visible-debt.trail.ts +16 -0
  92. package/src/trails/error-mapping-completeness.trail.ts +29 -0
  93. package/src/trails/example-valid.trail.ts +25 -0
  94. package/src/trails/fires-declarations.trail.ts +23 -0
  95. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  96. package/src/trails/implementation-returns-result.trail.ts +20 -0
  97. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  98. package/src/trails/incomplete-crud.trail.ts +39 -0
  99. package/src/trails/index.ts +78 -0
  100. package/src/trails/intent-propagation.trail.ts +30 -0
  101. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  102. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  103. package/src/trails/missing-reconcile.trail.ts +33 -0
  104. package/src/trails/missing-visibility.trail.ts +22 -0
  105. package/src/trails/no-destructured-compose.trail.ts +44 -0
  106. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  107. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  108. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  109. package/src/trails/no-native-error-result.trail.ts +18 -0
  110. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  111. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  112. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  113. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  114. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  115. package/src/trails/no-top-level-surface.trail.ts +43 -0
  116. package/src/trails/on-references-exist.trail.ts +21 -0
  117. package/src/trails/orphaned-signal.trail.ts +36 -0
  118. package/src/trails/owner-projection-parity.trail.ts +26 -0
  119. package/src/trails/pending-force.trail.ts +21 -0
  120. package/src/trails/permit-governance.trail.ts +51 -0
  121. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  122. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  123. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  124. package/src/trails/public-output-schema.trail.ts +55 -0
  125. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  126. package/src/trails/read-intent-fires.trail.ts +20 -0
  127. package/src/trails/reference-exists.trail.ts +25 -0
  128. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  129. package/src/trails/resource-declarations.trail.ts +25 -0
  130. package/src/trails/resource-exists.trail.ts +27 -0
  131. package/src/trails/resource-id-grammar.trail.ts +39 -0
  132. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  133. package/src/trails/run.ts +162 -0
  134. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  135. package/src/trails/schema.ts +194 -0
  136. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  137. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  138. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  139. package/src/trails/topo.ts +6 -0
  140. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  141. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  142. package/src/trails/valid-describe-refs.trail.ts +18 -0
  143. package/src/trails/valid-detour-contract.trail.ts +71 -0
  144. package/src/trails/version-gap.trail.ts +35 -0
  145. package/src/trails/version-pinned-compose.trail.ts +23 -0
  146. package/src/trails/version-without-examples.trail.ts +38 -0
  147. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  148. package/src/trails/warden-rules-use-ast.trail.ts +45 -0
  149. package/src/trails/webhook-route-collision.trail.ts +50 -0
  150. package/src/trails/wrap-rule.ts +213 -0
  151. package/src/workspaces.ts +238 -0
  152. package/.turbo/turbo-build.log +0 -1
  153. package/.turbo/turbo-lint.log +0 -3
  154. package/.turbo/turbo-typecheck.log +0 -1
  155. package/dist/cli.d.ts +0 -46
  156. package/dist/cli.d.ts.map +0 -1
  157. package/dist/cli.js +0 -221
  158. package/dist/cli.js.map +0 -1
  159. package/dist/drift.d.ts +0 -26
  160. package/dist/drift.d.ts.map +0 -1
  161. package/dist/drift.js +0 -27
  162. package/dist/drift.js.map +0 -1
  163. package/dist/formatters.d.ts +0 -29
  164. package/dist/formatters.d.ts.map +0 -1
  165. package/dist/formatters.js +0 -87
  166. package/dist/formatters.js.map +0 -1
  167. package/dist/index.d.ts +0 -26
  168. package/dist/index.d.ts.map +0 -1
  169. package/dist/index.js +0 -26
  170. package/dist/index.js.map +0 -1
  171. package/dist/rules/ast.d.ts +0 -41
  172. package/dist/rules/ast.d.ts.map +0 -1
  173. package/dist/rules/ast.js +0 -163
  174. package/dist/rules/ast.js.map +0 -1
  175. package/dist/rules/context-no-surface-types.d.ts +0 -12
  176. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  177. package/dist/rules/context-no-surface-types.js +0 -96
  178. package/dist/rules/context-no-surface-types.js.map +0 -1
  179. package/dist/rules/implementation-returns-result.d.ts +0 -13
  180. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  181. package/dist/rules/implementation-returns-result.js +0 -231
  182. package/dist/rules/implementation-returns-result.js.map +0 -1
  183. package/dist/rules/index.d.ts +0 -22
  184. package/dist/rules/index.d.ts.map +0 -1
  185. package/dist/rules/index.js +0 -41
  186. package/dist/rules/index.js.map +0 -1
  187. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  188. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  189. package/dist/rules/no-direct-impl-in-route.js +0 -46
  190. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  191. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  192. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  193. package/dist/rules/no-direct-implementation-call.js +0 -39
  194. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  195. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  196. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  197. package/dist/rules/no-sync-result-assumption.js +0 -98
  198. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  199. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  200. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  201. package/dist/rules/no-throw-in-detour-target.js +0 -87
  202. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  203. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  204. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  205. package/dist/rules/no-throw-in-implementation.js +0 -34
  206. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  207. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  208. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  209. package/dist/rules/prefer-schema-inference.js +0 -86
  210. package/dist/rules/prefer-schema-inference.js.map +0 -1
  211. package/dist/rules/scan.d.ts +0 -8
  212. package/dist/rules/scan.d.ts.map +0 -1
  213. package/dist/rules/scan.js +0 -32
  214. package/dist/rules/scan.js.map +0 -1
  215. package/dist/rules/specs.d.ts +0 -29
  216. package/dist/rules/specs.d.ts.map +0 -1
  217. package/dist/rules/specs.js +0 -192
  218. package/dist/rules/specs.js.map +0 -1
  219. package/dist/rules/structure.d.ts +0 -13
  220. package/dist/rules/structure.d.ts.map +0 -1
  221. package/dist/rules/structure.js +0 -142
  222. package/dist/rules/structure.js.map +0 -1
  223. package/dist/rules/types.d.ts +0 -52
  224. package/dist/rules/types.d.ts.map +0 -1
  225. package/dist/rules/types.js +0 -2
  226. package/dist/rules/types.js.map +0 -1
  227. package/dist/rules/valid-describe-refs.d.ts +0 -7
  228. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  229. package/dist/rules/valid-describe-refs.js +0 -51
  230. package/dist/rules/valid-describe-refs.js.map +0 -1
  231. package/dist/rules/valid-detour-refs.d.ts +0 -6
  232. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  233. package/dist/rules/valid-detour-refs.js +0 -116
  234. package/dist/rules/valid-detour-refs.js.map +0 -1
  235. package/src/__tests__/cli.test.ts +0 -198
  236. package/src/__tests__/drift.test.ts +0 -74
  237. package/src/__tests__/formatters.test.ts +0 -157
  238. package/src/__tests__/implementation-returns-result.test.ts +0 -75
  239. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  240. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  241. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  242. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  243. package/src/__tests__/rules.test.ts +0 -188
  244. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  245. package/src/rules/no-direct-impl-in-route.ts +0 -77
  246. package/src/rules/no-throw-in-detour-target.ts +0 -150
  247. package/src/rules/valid-detour-refs.ts +0 -187
  248. package/tsconfig.json +0 -9
  249. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,517 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { resolve, sep } from 'node:path';
3
+
4
+ import {
5
+ extractStringLiteral,
6
+ hasIgnoreCommentOnLine,
7
+ offsetToLine,
8
+ parse,
9
+ splitSourceLines,
10
+ walk,
11
+ } from './ast.js';
12
+ import type { AstNode } from './ast.js';
13
+ import type {
14
+ ProjectAwareWardenRule,
15
+ ProjectContext,
16
+ WardenDiagnostic,
17
+ } from './types.js';
18
+ import type { WardenImportResolution } from '../resolve.js';
19
+ import type { WardenPublicWorkspace } from '../workspaces.js';
20
+
21
+ const RULE_NAME = 'public-internal-deep-imports';
22
+ const ONTRAILS_SPECIFIER_PATTERN = /^(@ontrails\/[^/]+)(?:\/(.+))?$/;
23
+ const ROOT_BARREL_INTERNAL_RE_EXPORT_ALLOWLIST = new Set([
24
+ '@ontrails/tracing:./internal/dev-state.js',
25
+ ]);
26
+
27
+ interface ReExportSite {
28
+ readonly importSource: string;
29
+ readonly line: number;
30
+ }
31
+
32
+ const normalizePath = (path: string): string => path.replaceAll('\\', '/');
33
+
34
+ const normalizeRealPath = (path: string): string => {
35
+ try {
36
+ return normalizePath(realpathSync(path));
37
+ } catch {
38
+ return normalizePath(resolve(path));
39
+ }
40
+ };
41
+
42
+ const pathIsInside = (filePath: string, rootDir: string): boolean => {
43
+ const absoluteFilePath = normalizeRealPath(filePath);
44
+ const absoluteRootDir = normalizeRealPath(rootDir);
45
+ return (
46
+ absoluteFilePath === absoluteRootDir ||
47
+ absoluteFilePath.startsWith(`${absoluteRootDir}/`)
48
+ );
49
+ };
50
+
51
+ const packageNameFromSpecifier = (specifier: string): string | undefined => {
52
+ const match = ONTRAILS_SPECIFIER_PATTERN.exec(specifier);
53
+ return match?.[1];
54
+ };
55
+
56
+ const specifierHasSubpath = (specifier: string): boolean =>
57
+ Boolean(ONTRAILS_SPECIFIER_PATTERN.exec(specifier)?.[2]);
58
+
59
+ const sourcePackageNameForFile = (
60
+ filePath: string,
61
+ workspaces: ReadonlyMap<string, WardenPublicWorkspace>
62
+ ): string | undefined => {
63
+ for (const workspace of workspaces.values()) {
64
+ if (pathIsInside(filePath, workspace.rootDir)) {
65
+ return workspace.name;
66
+ }
67
+ }
68
+ return undefined;
69
+ };
70
+
71
+ const importResolutionsForFile = (
72
+ context: ProjectContext,
73
+ filePath: string
74
+ ): readonly WardenImportResolution[] =>
75
+ context.importResolutionsByFile?.get(filePath) ?? [];
76
+
77
+ const documentedImportResolutionsForFile = (
78
+ context: ProjectContext,
79
+ filePath: string
80
+ ): readonly WardenImportResolution[] =>
81
+ context.documentedImportResolutionsByFile?.get(filePath) ?? [];
82
+
83
+ const diagnosticMessage = (
84
+ resolution: WardenImportResolution,
85
+ packageName: string
86
+ ): string => {
87
+ if (resolution.errorKind === 'not-found') {
88
+ return `@ontrails specifier "${resolution.importSource}" could not be resolved from the public workspace package ${packageName}. Use the package root, an exported subpath, or the package binary when the package is bin-only.`;
89
+ }
90
+
91
+ return (
92
+ `@ontrails specifier "${resolution.importSource}" is not exported by ${packageName}. ` +
93
+ 'Use the package root or an exported subpath; if the API is missing, add an owner export follow-up instead of importing internals.'
94
+ );
95
+ };
96
+
97
+ const shouldReportResolution = (
98
+ resolution: WardenImportResolution,
99
+ workspace: WardenPublicWorkspace,
100
+ sourcePackageName: string | undefined,
101
+ isDocumentation: boolean
102
+ ): boolean => {
103
+ if (!isDocumentation && sourcePackageName === workspace.name) {
104
+ return false;
105
+ }
106
+
107
+ if (resolution.errorKind === 'package-path-not-exported') {
108
+ return true;
109
+ }
110
+
111
+ if (resolution.errorKind === 'not-found') {
112
+ return isDocumentation
113
+ ? specifierHasSubpath(resolution.importSource)
114
+ : resolution.importSource === workspace.name ||
115
+ specifierHasSubpath(resolution.importSource);
116
+ }
117
+
118
+ if (isDocumentation && specifierHasSubpath(resolution.importSource)) {
119
+ return !resolution.usesPublicExport;
120
+ }
121
+
122
+ return false;
123
+ };
124
+
125
+ const diagnosticsForResolutions = ({
126
+ context,
127
+ filePath,
128
+ isDocumentation,
129
+ sourceCode,
130
+ }: {
131
+ readonly context: ProjectContext;
132
+ readonly filePath: string;
133
+ readonly isDocumentation: boolean;
134
+ readonly sourceCode: string;
135
+ }): readonly WardenDiagnostic[] => {
136
+ const workspaces = context.publicWorkspaces;
137
+ if (!workspaces || workspaces.size === 0) {
138
+ return [];
139
+ }
140
+
141
+ const sourcePackageName = sourcePackageNameForFile(filePath, workspaces);
142
+ const lines = splitSourceLines(sourceCode);
143
+ const resolutions = isDocumentation
144
+ ? documentedImportResolutionsForFile(context, filePath)
145
+ : importResolutionsForFile(context, filePath);
146
+ const diagnostics: WardenDiagnostic[] = [];
147
+
148
+ for (const resolution of resolutions) {
149
+ if (hasIgnoreCommentOnLine(lines, resolution.line)) {
150
+ continue;
151
+ }
152
+
153
+ const packageName =
154
+ resolution.packageName ??
155
+ packageNameFromSpecifier(resolution.importSource);
156
+ const workspace = packageName ? workspaces.get(packageName) : undefined;
157
+ if (!packageName || !workspace) {
158
+ continue;
159
+ }
160
+
161
+ if (
162
+ shouldReportResolution(
163
+ resolution,
164
+ workspace,
165
+ sourcePackageName,
166
+ isDocumentation
167
+ )
168
+ ) {
169
+ diagnostics.push({
170
+ filePath,
171
+ line: resolution.line,
172
+ message: diagnosticMessage(resolution, packageName),
173
+ rule: RULE_NAME,
174
+ severity: 'error',
175
+ });
176
+ }
177
+ }
178
+
179
+ return diagnostics;
180
+ };
181
+
182
+ const isRootBarrel = (
183
+ filePath: string,
184
+ workspace: WardenPublicWorkspace
185
+ ): boolean => {
186
+ const rootExportTarget = workspace.exportTargets?.[workspace.name];
187
+ if (rootExportTarget) {
188
+ return normalizeRealPath(filePath) === rootExportTarget;
189
+ }
190
+ return (
191
+ normalizeRealPath(filePath) ===
192
+ normalizeRealPath(resolve(workspace.rootDir, 'src/index.ts'))
193
+ );
194
+ };
195
+
196
+ const rootBarrelWorkspace = (
197
+ filePath: string,
198
+ workspaces: ReadonlyMap<string, WardenPublicWorkspace>
199
+ ): WardenPublicWorkspace | undefined => {
200
+ for (const workspace of workspaces.values()) {
201
+ if (isRootBarrel(filePath, workspace)) {
202
+ return workspace;
203
+ }
204
+ }
205
+ return undefined;
206
+ };
207
+
208
+ const collectReExportSites = (
209
+ sourceCode: string,
210
+ filePath: string
211
+ ): readonly ReExportSite[] => {
212
+ const ast = parse(filePath, sourceCode);
213
+ if (!ast) {
214
+ return [];
215
+ }
216
+
217
+ const sites: ReExportSite[] = [];
218
+ walk(ast, (node) => {
219
+ if (
220
+ node.type !== 'ExportNamedDeclaration' &&
221
+ node.type !== 'ExportAllDeclaration'
222
+ ) {
223
+ return;
224
+ }
225
+
226
+ const { source } = node as unknown as { source?: AstNode };
227
+ const value = extractStringLiteral(source);
228
+ if (typeof value !== 'string') {
229
+ return;
230
+ }
231
+
232
+ sites.push({
233
+ importSource: value,
234
+ line: offsetToLine(sourceCode, node.start),
235
+ });
236
+ });
237
+ return sites;
238
+ };
239
+
240
+ const reExportResolution = (
241
+ resolutions: readonly WardenImportResolution[],
242
+ site: ReExportSite
243
+ ): WardenImportResolution | undefined =>
244
+ resolutions.find(
245
+ (resolution) =>
246
+ resolution.importSource === site.importSource &&
247
+ resolution.line === site.line
248
+ );
249
+
250
+ const isAllowlistedRootBarrelInternalExport = (
251
+ workspace: WardenPublicWorkspace,
252
+ importSource: string
253
+ ): boolean =>
254
+ ROOT_BARREL_INTERNAL_RE_EXPORT_ALLOWLIST.has(
255
+ `${workspace.name}:${importSource}`
256
+ );
257
+
258
+ const rootBarrelDiagnostics = (
259
+ sourceCode: string,
260
+ filePath: string,
261
+ context: ProjectContext
262
+ ): readonly WardenDiagnostic[] => {
263
+ const workspaces = context.publicWorkspaces;
264
+ if (!workspaces || workspaces.size === 0) {
265
+ return [];
266
+ }
267
+
268
+ const workspace = rootBarrelWorkspace(filePath, workspaces);
269
+ if (!workspace) {
270
+ return [];
271
+ }
272
+
273
+ const resolutions = importResolutionsForFile(context, filePath);
274
+ const lines = splitSourceLines(sourceCode);
275
+ const diagnostics: WardenDiagnostic[] = [];
276
+ for (const site of collectReExportSites(sourceCode, filePath)) {
277
+ if (isAllowlistedRootBarrelInternalExport(workspace, site.importSource)) {
278
+ continue;
279
+ }
280
+ if (hasIgnoreCommentOnLine(lines, site.line)) {
281
+ continue;
282
+ }
283
+
284
+ const resolution = reExportResolution(resolutions, site);
285
+ if (!resolution?.isInternalTarget) {
286
+ continue;
287
+ }
288
+
289
+ diagnostics.push({
290
+ filePath,
291
+ line: site.line,
292
+ message:
293
+ `${workspace.name} root barrel re-exports internal target "${site.importSource}". ` +
294
+ 'Move the symbol behind an explicit public module or keep it private to the package.',
295
+ rule: RULE_NAME,
296
+ severity: 'error',
297
+ });
298
+ }
299
+
300
+ return diagnostics;
301
+ };
302
+
303
+ const stripLeadingDotSlash = (path: string): string =>
304
+ path.startsWith('./') ? path.slice(2) : path;
305
+
306
+ const escapeRegExp = (value: string): string =>
307
+ value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
308
+
309
+ const wildcardPatternSource = (pattern: string): string => {
310
+ let regexSource = '';
311
+ for (let index = 0; index < pattern.length; index += 1) {
312
+ const char = pattern[index];
313
+ if (char === '*') {
314
+ if (pattern[index + 1] === '*') {
315
+ regexSource += '.*';
316
+ index += 1;
317
+ } else {
318
+ regexSource += '[^/]*';
319
+ }
320
+ continue;
321
+ }
322
+ regexSource += escapeRegExp(char ?? '');
323
+ }
324
+ return regexSource;
325
+ };
326
+
327
+ const segmentWildcardPatternCovers = (
328
+ filePath: string,
329
+ pattern: string
330
+ ): boolean => new RegExp(`^${wildcardPatternSource(pattern)}$`).test(filePath);
331
+
332
+ const deepWildcardPatternCovers = (
333
+ filePath: string,
334
+ pattern: string,
335
+ globIndex: number
336
+ ): boolean => {
337
+ const prefix = pattern.slice(0, globIndex + 1);
338
+ if (!filePath.startsWith(prefix)) {
339
+ return false;
340
+ }
341
+ const suffixPattern = pattern.slice(globIndex + '/**/'.length);
342
+ if (suffixPattern.length === 0) {
343
+ return true;
344
+ }
345
+ const remainingPath = filePath.slice(prefix.length);
346
+ const regexSource = `^(?:.*/)?${wildcardPatternSource(suffixPattern)}$`;
347
+ return new RegExp(regexSource).test(remainingPath);
348
+ };
349
+
350
+ const filePatternCovers = (filePath: string, pattern: string): boolean => {
351
+ const normalizedFilePath = normalizePath(stripLeadingDotSlash(filePath));
352
+ const normalizedPattern = normalizePath(stripLeadingDotSlash(pattern));
353
+ if (normalizedPattern.startsWith('!')) {
354
+ return false;
355
+ }
356
+ if (normalizedPattern === normalizedFilePath) {
357
+ return true;
358
+ }
359
+ if (normalizedPattern === '**') {
360
+ return true;
361
+ }
362
+ if (normalizedPattern.endsWith('/**')) {
363
+ const prefix = normalizedPattern.slice(0, -2);
364
+ return normalizedFilePath.startsWith(prefix);
365
+ }
366
+
367
+ const globIndex = normalizedPattern.indexOf('/**/');
368
+ if (globIndex === -1) {
369
+ if (normalizedPattern.includes('*')) {
370
+ return segmentWildcardPatternCovers(
371
+ normalizedFilePath,
372
+ normalizedPattern
373
+ );
374
+ }
375
+ return normalizedFilePath.startsWith(`${normalizedPattern}/`);
376
+ }
377
+
378
+ return deepWildcardPatternCovers(
379
+ normalizedFilePath,
380
+ normalizedPattern,
381
+ globIndex
382
+ );
383
+ };
384
+
385
+ const filesCoverTarget = (
386
+ files: readonly string[] | undefined,
387
+ target: string
388
+ ): boolean => {
389
+ if (!files || files.length === 0) {
390
+ return true;
391
+ }
392
+ let covered = false;
393
+ for (const pattern of files) {
394
+ const normalizedPattern = normalizePath(stripLeadingDotSlash(pattern));
395
+ if (normalizedPattern.startsWith('!')) {
396
+ if (filePatternCovers(target, normalizedPattern.slice(1))) {
397
+ covered = false;
398
+ }
399
+ continue;
400
+ }
401
+ if (filePatternCovers(target, normalizedPattern)) {
402
+ covered = true;
403
+ }
404
+ }
405
+ return covered;
406
+ };
407
+
408
+ const workspaceForPackageJson = (
409
+ filePath: string,
410
+ workspaces: ReadonlyMap<string, WardenPublicWorkspace>
411
+ ): WardenPublicWorkspace | undefined => {
412
+ const normalizedFilePath = normalizeRealPath(filePath);
413
+ for (const workspace of workspaces.values()) {
414
+ if (normalizePath(workspace.packageJsonPath) === normalizedFilePath) {
415
+ return workspace;
416
+ }
417
+ }
418
+ return undefined;
419
+ };
420
+
421
+ const binSurfaceDiagnostics = (
422
+ filePath: string,
423
+ context: ProjectContext
424
+ ): readonly WardenDiagnostic[] => {
425
+ if (
426
+ !filePath.endsWith(`${sep}package.json`) &&
427
+ !filePath.endsWith('/package.json')
428
+ ) {
429
+ return [];
430
+ }
431
+
432
+ const workspaces = context.publicWorkspaces;
433
+ if (!workspaces || workspaces.size === 0) {
434
+ return [];
435
+ }
436
+
437
+ const workspace = workspaceForPackageJson(filePath, workspaces);
438
+ if (!workspace) {
439
+ return [];
440
+ }
441
+
442
+ const diagnostics: WardenDiagnostic[] = [];
443
+ const binEntries = Object.entries(workspace.bin ?? {});
444
+ if (!workspace.hasExports && binEntries.length === 0) {
445
+ diagnostics.push({
446
+ filePath,
447
+ line: 1,
448
+ message:
449
+ `Public workspace ${workspace.name} has no exports map and no bin surface. ` +
450
+ 'Add an exports map for library APIs or declare the package binary surface explicitly.',
451
+ rule: RULE_NAME,
452
+ severity: 'error',
453
+ });
454
+ }
455
+
456
+ for (const [binName, target] of binEntries) {
457
+ const targetPath = resolve(workspace.rootDir, target);
458
+ if (!existsSync(targetPath)) {
459
+ diagnostics.push({
460
+ filePath,
461
+ line: 1,
462
+ message: `Bin "${binName}" for ${workspace.name} points at missing file ${target}.`,
463
+ rule: RULE_NAME,
464
+ severity: 'error',
465
+ });
466
+ }
467
+
468
+ if (!filesCoverTarget(workspace.files, target)) {
469
+ diagnostics.push({
470
+ filePath,
471
+ line: 1,
472
+ message:
473
+ `Bin "${binName}" for ${workspace.name} points at ${target}, ` +
474
+ 'but the package files list does not include that target.',
475
+ rule: RULE_NAME,
476
+ severity: 'error',
477
+ });
478
+ }
479
+ }
480
+
481
+ return diagnostics;
482
+ };
483
+
484
+ export const publicInternalDeepImports: ProjectAwareWardenRule = {
485
+ check(): readonly WardenDiagnostic[] {
486
+ return [];
487
+ },
488
+ checkWithContext(
489
+ sourceCode: string,
490
+ filePath: string,
491
+ context: ProjectContext
492
+ ): readonly WardenDiagnostic[] {
493
+ if (filePath.endsWith('.md')) {
494
+ return diagnosticsForResolutions({
495
+ context,
496
+ filePath,
497
+ isDocumentation: true,
498
+ sourceCode,
499
+ });
500
+ }
501
+
502
+ return [
503
+ ...diagnosticsForResolutions({
504
+ context,
505
+ filePath,
506
+ isDocumentation: false,
507
+ sourceCode,
508
+ }),
509
+ ...rootBarrelDiagnostics(sourceCode, filePath, context),
510
+ ...binSurfaceDiagnostics(filePath, context),
511
+ ];
512
+ },
513
+ description:
514
+ 'Keep @ontrails/* imports, docs specifiers, root barrels, and bin-only surfaces aligned with public package exports.',
515
+ name: RULE_NAME,
516
+ severity: 'error',
517
+ };
@@ -0,0 +1,29 @@
1
+ import { filterSurfaceTrails } from '@ontrails/core';
2
+ import type { AnyTrail, Topo } from '@ontrails/core';
3
+
4
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
5
+
6
+ const RULE_NAME = 'public-output-schema';
7
+ const TOPO_FILE = '<topo>';
8
+
9
+ const diagnosticForTrail = (trail: AnyTrail): WardenDiagnostic => ({
10
+ filePath: TOPO_FILE,
11
+ line: 1,
12
+ message:
13
+ `Trail "${trail.id}" is visible to public MCP/HTTP surface projection but does not declare an output schema. ` +
14
+ 'Add an explicit output schema, or mark the trail visibility as internal if it is composition-only.',
15
+ rule: RULE_NAME,
16
+ severity: 'error',
17
+ });
18
+
19
+ export const publicOutputSchema: TopoAwareWardenRule = {
20
+ checkTopo(topo: Topo): readonly WardenDiagnostic[] {
21
+ return filterSurfaceTrails(topo.list()).flatMap((trail) =>
22
+ trail.output === undefined ? [diagnosticForTrail(trail)] : []
23
+ );
24
+ },
25
+ description:
26
+ 'Require public MCP/HTTP surface-eligible trails to declare output schemas.',
27
+ name: RULE_NAME,
28
+ severity: 'error',
29
+ };
@@ -0,0 +1,150 @@
1
+ import { filterSurfaceTrails, zodToJsonSchema } from '@ontrails/core';
2
+ import type { AnyTrail, Topo } from '@ontrails/core';
3
+
4
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
5
+
6
+ const RULE_NAME = 'public-union-output-discriminants';
7
+
8
+ type JsonSchema = Readonly<Record<string, unknown>>;
9
+
10
+ interface ObjectBranch {
11
+ readonly properties: Readonly<Record<string, JsonSchema>>;
12
+ readonly required: ReadonlySet<string>;
13
+ }
14
+
15
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
16
+ Boolean(value && typeof value === 'object' && !Array.isArray(value));
17
+
18
+ const isJsonSchema = (value: unknown): value is JsonSchema => isRecord(value);
19
+
20
+ const schemaForTrailOutput = (trail: AnyTrail): JsonSchema | undefined => {
21
+ if (!trail.output) {
22
+ return undefined;
23
+ }
24
+ try {
25
+ return zodToJsonSchema(trail.output);
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ };
30
+
31
+ const readProperties = (
32
+ schema: JsonSchema
33
+ ): Readonly<Record<string, JsonSchema>> | undefined => {
34
+ const { properties } = schema;
35
+ if (!isRecord(properties)) {
36
+ return undefined;
37
+ }
38
+ const entries = Object.entries(properties);
39
+ if (!entries.every(([, value]) => isJsonSchema(value))) {
40
+ return undefined;
41
+ }
42
+ return properties as Readonly<Record<string, JsonSchema>>;
43
+ };
44
+
45
+ const readRequired = (schema: JsonSchema): ReadonlySet<string> => {
46
+ const { required } = schema;
47
+ if (!Array.isArray(required)) {
48
+ return new Set();
49
+ }
50
+ return new Set(
51
+ required.filter((entry): entry is string => typeof entry === 'string')
52
+ );
53
+ };
54
+
55
+ const objectBranchFromSchema = (schema: unknown): ObjectBranch | undefined => {
56
+ if (!isJsonSchema(schema) || schema['type'] !== 'object') {
57
+ return undefined;
58
+ }
59
+ const properties = readProperties(schema);
60
+ if (!properties) {
61
+ return undefined;
62
+ }
63
+ return { properties, required: readRequired(schema) };
64
+ };
65
+
66
+ const objectBranchesFromAnyOf = (
67
+ schema: JsonSchema
68
+ ): readonly ObjectBranch[] | undefined => {
69
+ const { anyOf } = schema;
70
+ if (!Array.isArray(anyOf) || anyOf.length < 2) {
71
+ return undefined;
72
+ }
73
+ const branches = anyOf.flatMap((branch) => {
74
+ const objectBranch = objectBranchFromSchema(branch);
75
+ return objectBranch ? [objectBranch] : [];
76
+ });
77
+ return branches.length >= 2 ? branches : undefined;
78
+ };
79
+
80
+ const hasConst = (schema: JsonSchema): boolean =>
81
+ Object.hasOwn(schema, 'const');
82
+
83
+ const constValue = (schema: JsonSchema): unknown => schema['const'];
84
+
85
+ const constKey = (value: unknown): string => JSON.stringify(value);
86
+
87
+ const branchLiteralForKey = (
88
+ branch: ObjectBranch,
89
+ key: string
90
+ ): unknown | undefined => {
91
+ if (!branch.required.has(key)) {
92
+ return undefined;
93
+ }
94
+ const property = branch.properties[key];
95
+ return property && hasConst(property) ? constValue(property) : undefined;
96
+ };
97
+
98
+ const hasRequiredLiteralDiscriminant = (
99
+ branches: readonly ObjectBranch[]
100
+ ): boolean => {
101
+ const [first] = branches;
102
+ if (!first) {
103
+ return false;
104
+ }
105
+
106
+ return Object.keys(first.properties).some((key) => {
107
+ const values = branches.map((branch) => branchLiteralForKey(branch, key));
108
+ return (
109
+ values.every((value) => value !== undefined) &&
110
+ new Set(values.map(constKey)).size === branches.length
111
+ );
112
+ });
113
+ };
114
+
115
+ const diagnosticForTrail = (trail: AnyTrail): WardenDiagnostic => ({
116
+ filePath: '<topo>',
117
+ line: 1,
118
+ message:
119
+ `Trail "${trail.id}" exposes a public output anyOf with object variants but no required literal discriminator. ` +
120
+ 'Add a shared z.literal(...) field or z.discriminatedUnion(...) so surfaces and agents can select the output branch.',
121
+ rule: RULE_NAME,
122
+ severity: 'error',
123
+ });
124
+
125
+ const diagnoseTrail = (trail: AnyTrail): WardenDiagnostic | undefined => {
126
+ const schema = schemaForTrailOutput(trail);
127
+ if (!schema) {
128
+ return undefined;
129
+ }
130
+ const objectBranches = objectBranchesFromAnyOf(schema);
131
+ if (!objectBranches) {
132
+ return undefined;
133
+ }
134
+ return hasRequiredLiteralDiscriminant(objectBranches)
135
+ ? undefined
136
+ : diagnosticForTrail(trail);
137
+ };
138
+
139
+ export const publicUnionOutputDiscriminants: TopoAwareWardenRule = {
140
+ checkTopo(topo: Topo): readonly WardenDiagnostic[] {
141
+ return filterSurfaceTrails(topo.list()).flatMap((trail) => {
142
+ const diagnostic = diagnoseTrail(trail);
143
+ return diagnostic ? [diagnostic] : [];
144
+ });
145
+ },
146
+ description:
147
+ 'Require public trail output object unions to expose a required literal discriminator.',
148
+ name: RULE_NAME,
149
+ severity: 'error',
150
+ };