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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/CHANGELOG.md +674 -9
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +28 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +137 -0
  7. package/src/cli.ts +1451 -104
  8. package/src/command.ts +966 -0
  9. package/src/config.ts +193 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +122 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +217 -14
  16. package/src/project-context.ts +192 -0
  17. package/src/project-rules.ts +290 -0
  18. package/src/resolve.ts +531 -0
  19. package/src/rules/activation-orphan.ts +97 -0
  20. package/src/rules/ast.ts +4008 -86
  21. package/src/rules/circular-refs.ts +154 -0
  22. package/src/rules/cli-command-route-coherence.ts +177 -0
  23. package/src/rules/composes-declarations.ts +837 -0
  24. package/src/rules/context-no-surface-types.ts +78 -15
  25. package/src/rules/contour-exists.ts +251 -0
  26. package/src/rules/contour-ids.ts +15 -0
  27. package/src/rules/dead-internal-trail.ts +161 -0
  28. package/src/rules/dead-public-trail.ts +258 -0
  29. package/src/rules/draft-file-marking.ts +160 -0
  30. package/src/rules/draft-visible-debt.ts +87 -0
  31. package/src/rules/duplicate-public-contract.ts +91 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +735 -0
  35. package/src/rules/implementation-returns-result.ts +1409 -166
  36. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  37. package/src/rules/incomplete-crud.ts +581 -0
  38. package/src/rules/index.ts +235 -18
  39. package/src/rules/intent-propagation.ts +127 -0
  40. package/src/rules/layer-field-name-drift.ts +102 -0
  41. package/src/rules/library-projection-coherence.ts +100 -0
  42. package/src/rules/metadata.ts +755 -0
  43. package/src/rules/missing-reconcile.ts +98 -0
  44. package/src/rules/missing-visibility.ts +110 -0
  45. package/src/rules/no-destructured-compose.ts +194 -0
  46. package/src/rules/no-dev-permit-in-source.ts +99 -0
  47. package/src/rules/no-direct-implementation-call.ts +7 -7
  48. package/src/rules/no-legacy-layer-imports.ts +211 -0
  49. package/src/rules/no-native-error-result.ts +118 -0
  50. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  51. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  52. package/src/rules/no-sync-result-assumption.ts +1135 -98
  53. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  54. package/src/rules/no-throw-in-implementation.ts +10 -9
  55. package/src/rules/no-top-level-surface.ts +371 -0
  56. package/src/rules/on-references-exist.ts +194 -0
  57. package/src/rules/orphaned-signal.ts +150 -0
  58. package/src/rules/owner-projection-parity.ts +143 -0
  59. package/src/rules/permit-governance.ts +25 -0
  60. package/src/rules/public-export-example-coverage.ts +562 -0
  61. package/src/rules/public-internal-deep-imports.ts +457 -0
  62. package/src/rules/public-output-schema.ts +29 -0
  63. package/src/rules/public-union-output-discriminants.ts +150 -0
  64. package/src/rules/read-intent-fires.ts +187 -0
  65. package/src/rules/reference-exists.ts +98 -0
  66. package/src/rules/registry-names.ts +155 -0
  67. package/src/rules/resolved-import-boundary.ts +146 -0
  68. package/src/rules/resource-declarations.ts +693 -0
  69. package/src/rules/resource-exists.ts +179 -0
  70. package/src/rules/resource-id-grammar.ts +65 -0
  71. package/src/rules/resource-mock-coverage.ts +115 -0
  72. package/src/rules/scan.ts +38 -25
  73. package/src/rules/scheduled-destroy-intent.ts +44 -0
  74. package/src/rules/signal-graph-coaching.ts +191 -0
  75. package/src/rules/specs.ts +9 -5
  76. package/src/rules/static-resource-accessor-preference.ts +649 -0
  77. package/src/rules/surface-facet-coherence.ts +362 -0
  78. package/src/rules/trail-fork-coaching.ts +616 -0
  79. package/src/rules/trail-versioning-source.ts +1072 -0
  80. package/src/rules/trail-versioning-topo.ts +172 -0
  81. package/src/rules/types.ts +297 -8
  82. package/src/rules/unmaterialized-activation-source.ts +84 -0
  83. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  84. package/src/rules/valid-describe-refs.ts +162 -32
  85. package/src/rules/valid-detour-contract.ts +78 -0
  86. package/src/rules/warden-export-symmetry.ts +540 -0
  87. package/src/rules/warden-rules-use-ast.ts +1114 -0
  88. package/src/rules/webhook-route-collision.ts +243 -0
  89. package/src/trails/activation-orphan.trail.ts +84 -0
  90. package/src/trails/circular-refs.trail.ts +29 -0
  91. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  92. package/src/trails/composes-declarations.trail.ts +22 -0
  93. package/src/trails/context-no-surface-types.trail.ts +21 -0
  94. package/src/trails/contour-exists.trail.ts +21 -0
  95. package/src/trails/dead-internal-trail.trail.ts +26 -0
  96. package/src/trails/dead-public-trail.trail.ts +31 -0
  97. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  98. package/src/trails/draft-file-marking.trail.ts +16 -0
  99. package/src/trails/draft-visible-debt.trail.ts +16 -0
  100. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  101. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  102. package/src/trails/example-valid.trail.ts +25 -0
  103. package/src/trails/fires-declarations.trail.ts +23 -0
  104. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  105. package/src/trails/implementation-returns-result.trail.ts +20 -0
  106. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  107. package/src/trails/incomplete-crud.trail.ts +39 -0
  108. package/src/trails/index.ts +83 -0
  109. package/src/trails/intent-propagation.trail.ts +30 -0
  110. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  111. package/src/trails/library-projection-coherence.trail.ts +43 -0
  112. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  113. package/src/trails/missing-reconcile.trail.ts +33 -0
  114. package/src/trails/missing-visibility.trail.ts +22 -0
  115. package/src/trails/no-destructured-compose.trail.ts +44 -0
  116. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  117. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  118. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  119. package/src/trails/no-native-error-result.trail.ts +18 -0
  120. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  121. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  122. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  123. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  124. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  125. package/src/trails/no-top-level-surface.trail.ts +43 -0
  126. package/src/trails/on-references-exist.trail.ts +21 -0
  127. package/src/trails/orphaned-signal.trail.ts +36 -0
  128. package/src/trails/owner-projection-parity.trail.ts +26 -0
  129. package/src/trails/pending-force.trail.ts +21 -0
  130. package/src/trails/permit-governance.trail.ts +51 -0
  131. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  132. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  133. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  134. package/src/trails/public-output-schema.trail.ts +55 -0
  135. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  136. package/src/trails/read-intent-fires.trail.ts +20 -0
  137. package/src/trails/reference-exists.trail.ts +25 -0
  138. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  139. package/src/trails/resource-declarations.trail.ts +25 -0
  140. package/src/trails/resource-exists.trail.ts +27 -0
  141. package/src/trails/resource-id-grammar.trail.ts +39 -0
  142. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  143. package/src/trails/run.ts +162 -0
  144. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  145. package/src/trails/schema.ts +198 -0
  146. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  147. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  148. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  149. package/src/trails/topo.ts +6 -0
  150. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  151. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  152. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  153. package/src/trails/valid-describe-refs.trail.ts +18 -0
  154. package/src/trails/valid-detour-contract.trail.ts +71 -0
  155. package/src/trails/version-gap.trail.ts +35 -0
  156. package/src/trails/version-pinned-compose.trail.ts +23 -0
  157. package/src/trails/version-without-examples.trail.ts +38 -0
  158. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  159. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  160. package/src/trails/webhook-route-collision.trail.ts +50 -0
  161. package/src/trails/wrap-rule.ts +214 -0
  162. package/src/workspaces.ts +199 -0
  163. package/.turbo/turbo-build.log +0 -1
  164. package/.turbo/turbo-lint.log +0 -3
  165. package/.turbo/turbo-typecheck.log +0 -1
  166. package/dist/cli.d.ts +0 -46
  167. package/dist/cli.d.ts.map +0 -1
  168. package/dist/cli.js +0 -221
  169. package/dist/cli.js.map +0 -1
  170. package/dist/drift.d.ts +0 -26
  171. package/dist/drift.d.ts.map +0 -1
  172. package/dist/drift.js +0 -27
  173. package/dist/drift.js.map +0 -1
  174. package/dist/formatters.d.ts +0 -29
  175. package/dist/formatters.d.ts.map +0 -1
  176. package/dist/formatters.js +0 -87
  177. package/dist/formatters.js.map +0 -1
  178. package/dist/index.d.ts +0 -26
  179. package/dist/index.d.ts.map +0 -1
  180. package/dist/index.js +0 -26
  181. package/dist/index.js.map +0 -1
  182. package/dist/rules/ast.d.ts +0 -41
  183. package/dist/rules/ast.d.ts.map +0 -1
  184. package/dist/rules/ast.js +0 -163
  185. package/dist/rules/ast.js.map +0 -1
  186. package/dist/rules/context-no-surface-types.d.ts +0 -12
  187. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  188. package/dist/rules/context-no-surface-types.js +0 -96
  189. package/dist/rules/context-no-surface-types.js.map +0 -1
  190. package/dist/rules/implementation-returns-result.d.ts +0 -13
  191. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  192. package/dist/rules/implementation-returns-result.js +0 -277
  193. package/dist/rules/implementation-returns-result.js.map +0 -1
  194. package/dist/rules/index.d.ts +0 -22
  195. package/dist/rules/index.d.ts.map +0 -1
  196. package/dist/rules/index.js +0 -41
  197. package/dist/rules/index.js.map +0 -1
  198. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  199. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  200. package/dist/rules/no-direct-impl-in-route.js +0 -46
  201. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  202. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  203. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  204. package/dist/rules/no-direct-implementation-call.js +0 -39
  205. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  206. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  207. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  208. package/dist/rules/no-sync-result-assumption.js +0 -98
  209. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  210. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  211. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  212. package/dist/rules/no-throw-in-detour-target.js +0 -87
  213. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  214. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  215. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  216. package/dist/rules/no-throw-in-implementation.js +0 -34
  217. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  218. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  219. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  220. package/dist/rules/prefer-schema-inference.js +0 -86
  221. package/dist/rules/prefer-schema-inference.js.map +0 -1
  222. package/dist/rules/scan.d.ts +0 -8
  223. package/dist/rules/scan.d.ts.map +0 -1
  224. package/dist/rules/scan.js +0 -32
  225. package/dist/rules/scan.js.map +0 -1
  226. package/dist/rules/specs.d.ts +0 -29
  227. package/dist/rules/specs.d.ts.map +0 -1
  228. package/dist/rules/specs.js +0 -192
  229. package/dist/rules/specs.js.map +0 -1
  230. package/dist/rules/structure.d.ts +0 -13
  231. package/dist/rules/structure.d.ts.map +0 -1
  232. package/dist/rules/structure.js +0 -142
  233. package/dist/rules/structure.js.map +0 -1
  234. package/dist/rules/types.d.ts +0 -52
  235. package/dist/rules/types.d.ts.map +0 -1
  236. package/dist/rules/types.js +0 -2
  237. package/dist/rules/types.js.map +0 -1
  238. package/dist/rules/valid-describe-refs.d.ts +0 -7
  239. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  240. package/dist/rules/valid-describe-refs.js +0 -51
  241. package/dist/rules/valid-describe-refs.js.map +0 -1
  242. package/dist/rules/valid-detour-refs.d.ts +0 -6
  243. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  244. package/dist/rules/valid-detour-refs.js +0 -116
  245. package/dist/rules/valid-detour-refs.js.map +0 -1
  246. package/src/__tests__/cli.test.ts +0 -198
  247. package/src/__tests__/drift.test.ts +0 -74
  248. package/src/__tests__/formatters.test.ts +0 -157
  249. package/src/__tests__/implementation-returns-result.test.ts +0 -128
  250. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  251. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  252. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  253. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  254. package/src/__tests__/rules.test.ts +0 -215
  255. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  256. package/src/rules/no-direct-impl-in-route.ts +0 -77
  257. package/src/rules/no-throw-in-detour-target.ts +0 -150
  258. package/src/rules/valid-detour-refs.ts +0 -187
  259. package/tsconfig.json +0 -9
  260. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,258 @@
1
+ import {
2
+ collectComposeTargetTrailIds,
3
+ findConfigProperty,
4
+ findTrailDefinitions,
5
+ getNodeDeclaration,
6
+ getNodeDeclarations,
7
+ getNodeId,
8
+ getNodeInit,
9
+ getNodeLocal,
10
+ getNodeName,
11
+ getNodeSpecifiers,
12
+ getNodeValue,
13
+ getStringValue,
14
+ isStringLiteral,
15
+ offsetToLine,
16
+ parse,
17
+ walkWithParents,
18
+ } from './ast.js';
19
+ import type { AstNode, AstParentContext } from './ast.js';
20
+ import { isTestFile } from './scan.js';
21
+ import type {
22
+ ProjectAwareWardenRule,
23
+ ProjectContext,
24
+ WardenDiagnostic,
25
+ } from './types.js';
26
+
27
+ const RULE_NAME = 'dead-public-trail';
28
+
29
+ const isNonEmptyActivationValue = (onValue: AstNode): boolean => {
30
+ if (onValue.type === 'Identifier') {
31
+ return true;
32
+ }
33
+ if (onValue.type !== 'ArrayExpression') {
34
+ return false;
35
+ }
36
+ const elements = onValue['elements'] as readonly AstNode[] | undefined;
37
+ return (elements?.length ?? 0) > 0;
38
+ };
39
+
40
+ const hasOnActivation = (config: AstNode): boolean => {
41
+ const onProp = findConfigProperty(config, 'on');
42
+ const onValue = onProp?.value as AstNode | undefined;
43
+ return onValue ? isNonEmptyActivationValue(onValue) : false;
44
+ };
45
+
46
+ const hasExplicitInternalVisibility = (config: AstNode): boolean => {
47
+ const visibilityProp = findConfigProperty(config, 'visibility');
48
+ const visibilityValue = visibilityProp?.value as AstNode | undefined;
49
+ return (
50
+ !!visibilityValue &&
51
+ isStringLiteral(visibilityValue) &&
52
+ getStringValue(visibilityValue) === 'internal'
53
+ );
54
+ };
55
+
56
+ const hasLegacyMetaInternal = (config: AstNode): boolean => {
57
+ const metaProp = findConfigProperty(config, 'meta');
58
+ const metaValue = metaProp?.value as AstNode | undefined;
59
+ if (!metaValue || metaValue.type !== 'ObjectExpression') {
60
+ return false;
61
+ }
62
+ const internalProp = findConfigProperty(metaValue, 'internal');
63
+ const internalValue = internalProp?.value as AstNode | undefined;
64
+ return (
65
+ internalValue?.type === 'BooleanLiteral' &&
66
+ getNodeValue(internalValue) === true
67
+ );
68
+ };
69
+
70
+ const isInternalTrail = (config: AstNode): boolean =>
71
+ hasExplicitInternalVisibility(config) || hasLegacyMetaInternal(config);
72
+
73
+ const isExportDeclaration = (node: AstNode | null | undefined): boolean =>
74
+ node?.type === 'ExportNamedDeclaration' ||
75
+ node?.type === 'ExportDefaultDeclaration';
76
+
77
+ const identifierName = (node: AstNode | undefined): string | null =>
78
+ node?.type === 'Identifier' ? (getNodeName(node) ?? null) : null;
79
+
80
+ const trailBindingStarts = (ast: AstNode): ReadonlyMap<string, number> => {
81
+ const trailStarts = new Set(
82
+ findTrailDefinitions(ast)
83
+ .filter((definition) => definition.kind === 'trail')
84
+ .map((definition) => definition.start)
85
+ );
86
+ const bindings = new Map<string, number>();
87
+
88
+ walkWithParents(ast, (node: AstNode) => {
89
+ if (node.type !== 'VariableDeclarator') {
90
+ return;
91
+ }
92
+ const id = getNodeId(node);
93
+ const init = getNodeInit(node);
94
+ const name = identifierName(id);
95
+ if (name && init && trailStarts.has(init.start)) {
96
+ bindings.set(name, init.start);
97
+ }
98
+ });
99
+
100
+ return bindings;
101
+ };
102
+
103
+ const addExportedSpecifierStarts = (
104
+ node: AstNode,
105
+ bindings: ReadonlyMap<string, number>,
106
+ exported: Set<number>
107
+ ): void => {
108
+ const specifiers = getNodeSpecifiers(node) ?? [];
109
+ for (const specifier of specifiers) {
110
+ const local = getNodeLocal(specifier);
111
+ const name = identifierName(local);
112
+ const start = name ? bindings.get(name) : undefined;
113
+ if (start !== undefined) {
114
+ exported.add(start);
115
+ }
116
+ }
117
+ };
118
+
119
+ const exportedTrailStarts = (ast: AstNode): ReadonlySet<number> => {
120
+ const exported = new Set<number>();
121
+ const bindings = trailBindingStarts(ast);
122
+
123
+ walkWithParents(ast, (node: AstNode, context: AstParentContext) => {
124
+ if (node.type !== 'CallExpression') {
125
+ return;
126
+ }
127
+
128
+ // Catch `export default trail(...)`. Exported variable declarations are
129
+ // handled by the VariableDeclaration walk below.
130
+ if (isExportDeclaration(context.parent)) {
131
+ exported.add(node.start);
132
+ }
133
+ });
134
+
135
+ walkWithParents(ast, (node: AstNode, context: AstParentContext) => {
136
+ if (node.type !== 'VariableDeclaration') {
137
+ return;
138
+ }
139
+ if (!isExportDeclaration(context.parent)) {
140
+ return;
141
+ }
142
+ const declarations = getNodeDeclarations(node) ?? [];
143
+ for (const declaration of declarations) {
144
+ const init = getNodeInit(declaration);
145
+ if (init?.type === 'CallExpression') {
146
+ exported.add(init.start);
147
+ }
148
+ }
149
+ });
150
+
151
+ walkWithParents(ast, (node: AstNode) => {
152
+ if (node.type === 'ExportNamedDeclaration') {
153
+ addExportedSpecifierStarts(node, bindings, exported);
154
+ return;
155
+ }
156
+
157
+ if (node.type !== 'ExportDefaultDeclaration') {
158
+ return;
159
+ }
160
+ const declaration = getNodeDeclaration(node);
161
+ const name = identifierName(declaration);
162
+ const start = name ? bindings.get(name) : undefined;
163
+ if (start !== undefined) {
164
+ exported.add(start);
165
+ }
166
+ });
167
+
168
+ return exported;
169
+ };
170
+
171
+ const buildDiagnostic = (
172
+ trailId: string,
173
+ filePath: string,
174
+ line: number
175
+ ): WardenDiagnostic => ({
176
+ filePath,
177
+ line,
178
+ message: `Exported public trail "${trailId}" is not registered in a configured app topo, composed by another trail, or activated by on:. Anchor the contract in a topo, compose it, mark it internal, or remove the public export.`,
179
+ rule: RULE_NAME,
180
+ severity: 'warn',
181
+ });
182
+
183
+ const unionComposeTargetIds = (
184
+ ast: AstNode | null,
185
+ sourceCode: string,
186
+ context: ProjectContext
187
+ ): ReadonlySet<string> => {
188
+ const local = ast
189
+ ? collectComposeTargetTrailIds(ast, sourceCode)
190
+ : new Set<string>();
191
+ return context.composeTargetTrailIds
192
+ ? new Set([...context.composeTargetTrailIds, ...local])
193
+ : local;
194
+ };
195
+
196
+ const checkDeadPublicTrails = (
197
+ ast: AstNode | null,
198
+ sourceCode: string,
199
+ filePath: string,
200
+ context: ProjectContext
201
+ ): readonly WardenDiagnostic[] => {
202
+ if (isTestFile(filePath) || !ast || !context.topoTrailIds) {
203
+ return [];
204
+ }
205
+
206
+ const exportedStarts = exportedTrailStarts(ast);
207
+ const composeTargetTrailIds = unionComposeTargetIds(ast, sourceCode, context);
208
+ const diagnostics: WardenDiagnostic[] = [];
209
+
210
+ for (const def of findTrailDefinitions(ast)) {
211
+ if (
212
+ def.kind !== 'trail' ||
213
+ isInternalTrail(def.config) ||
214
+ !exportedStarts.has(def.start)
215
+ ) {
216
+ continue;
217
+ }
218
+
219
+ if (
220
+ hasOnActivation(def.config) ||
221
+ composeTargetTrailIds.has(def.id) ||
222
+ context.topoTrailIds.has(def.id)
223
+ ) {
224
+ continue;
225
+ }
226
+
227
+ diagnostics.push(
228
+ buildDiagnostic(def.id, filePath, offsetToLine(sourceCode, def.start))
229
+ );
230
+ }
231
+
232
+ return diagnostics;
233
+ };
234
+
235
+ export const deadPublicTrail: ProjectAwareWardenRule = {
236
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
237
+ const ast = parse(filePath, sourceCode);
238
+ return checkDeadPublicTrails(ast, sourceCode, filePath, {
239
+ knownTrailIds: new Set<string>(),
240
+ });
241
+ },
242
+ checkWithContext(
243
+ sourceCode: string,
244
+ filePath: string,
245
+ context: ProjectContext
246
+ ): readonly WardenDiagnostic[] {
247
+ return checkDeadPublicTrails(
248
+ parse(filePath, sourceCode),
249
+ sourceCode,
250
+ filePath,
251
+ context
252
+ );
253
+ },
254
+ description:
255
+ 'Warn when an exported public trail is neither registered in configured app topos nor reachable through composition or activation.',
256
+ name: RULE_NAME,
257
+ severity: 'warn',
258
+ };
@@ -0,0 +1,160 @@
1
+ import { isDraftId } from '@ontrails/core';
2
+
3
+ import { isDraftMarkedFile } from '../draft.js';
4
+ import {
5
+ collectFrameworkDraftPrefixConstantOffsets,
6
+ findStringLiterals,
7
+ hasIgnoreCommentOnLine,
8
+ offsetToLine,
9
+ parse,
10
+ splitSourceLines,
11
+ } from './ast.js';
12
+ import type { StringLiteralMatch } from './ast.js';
13
+ import type { WardenDiagnostic, WardenRule } from './types.js';
14
+
15
+ const messageForMissingMarker = (draftId: string): string =>
16
+ `Draft id "${draftId}" appears in source, but the file is not draft-marked. ` +
17
+ 'Rename it with an _draft. prefix or a .draft. trailing segment.';
18
+
19
+ const makeDiagnostic = (
20
+ sourceCode: string,
21
+ filePath: string,
22
+ start: number,
23
+ message: string,
24
+ severity: WardenDiagnostic['severity']
25
+ ): WardenDiagnostic => ({
26
+ filePath,
27
+ line: offsetToLine(sourceCode, start),
28
+ message,
29
+ rule: 'draft-file-marking',
30
+ severity,
31
+ });
32
+
33
+ const collectDraftMatches = (
34
+ sourceCode: string,
35
+ filePath: string,
36
+ ast: NonNullable<ReturnType<typeof parse>>
37
+ ): StringLiteralMatch[] => {
38
+ const frameworkConstantOffsets = collectFrameworkDraftPrefixConstantOffsets(
39
+ ast,
40
+ filePath
41
+ );
42
+ const lines = splitSourceLines(sourceCode);
43
+ return findStringLiterals(ast, (value) => isDraftId(value)).filter(
44
+ (match) => {
45
+ if (frameworkConstantOffsets.has(match.start)) {
46
+ return false;
47
+ }
48
+ if (
49
+ hasIgnoreCommentOnLine(lines, offsetToLine(sourceCode, match.start))
50
+ ) {
51
+ return false;
52
+ }
53
+ return true;
54
+ }
55
+ );
56
+ };
57
+
58
+ const draftMissingMarkerDiagnostic = (
59
+ sourceCode: string,
60
+ filePath: string,
61
+ ast: NonNullable<ReturnType<typeof parse>>
62
+ ): WardenDiagnostic | null => {
63
+ const draftMatches = collectDraftMatches(sourceCode, filePath, ast);
64
+ if (!draftMatches.length || isDraftMarkedFile(filePath)) {
65
+ return null;
66
+ }
67
+
68
+ const [first] = draftMatches;
69
+ if (!first) {
70
+ return null;
71
+ }
72
+
73
+ return makeDiagnostic(
74
+ sourceCode,
75
+ filePath,
76
+ first.start,
77
+ messageForMissingMarker(first.value),
78
+ 'error'
79
+ );
80
+ };
81
+
82
+ const draftMarkedWithoutIdsDiagnostic = (
83
+ filePath: string,
84
+ ast: NonNullable<ReturnType<typeof parse>>
85
+ ): WardenDiagnostic | null => {
86
+ // Deciding whether the file's `_draft.` marker is still warranted is a
87
+ // question about *all* draft ids present in source, not just the unsuppressed
88
+ // ones. Pragma-suppressed ids still justify a draft-marked filename — a user
89
+ // intentionally silencing them has not removed the draft content. We
90
+ // therefore filter only the framework-constant declarations (which are not
91
+ // draft ids at all) and bypass the pragma filter that `collectDraftMatches`
92
+ // applies.
93
+ const frameworkConstantOffsets = collectFrameworkDraftPrefixConstantOffsets(
94
+ ast,
95
+ filePath
96
+ );
97
+ const unsuppressedDraftIds = findStringLiterals(ast, (value) =>
98
+ isDraftId(value)
99
+ ).filter((match) => !frameworkConstantOffsets.has(match.start));
100
+
101
+ if (unsuppressedDraftIds.length > 0) {
102
+ return null;
103
+ }
104
+
105
+ if (!isDraftMarkedFile(filePath)) {
106
+ return null;
107
+ }
108
+
109
+ return {
110
+ filePath,
111
+ line: 1,
112
+ message:
113
+ 'File is draft-marked but no longer contains draft ids. Remove the draft filename marker or finish the promotion cleanup.',
114
+ rule: 'draft-file-marking',
115
+ severity: 'warn',
116
+ };
117
+ };
118
+
119
+ const collectDraftFileMarkingDiagnostics = (
120
+ sourceCode: string,
121
+ filePath: string,
122
+ ast: NonNullable<ReturnType<typeof parse>>
123
+ ): WardenDiagnostic[] => {
124
+ const missingMarkerDiagnostic = draftMissingMarkerDiagnostic(
125
+ sourceCode,
126
+ filePath,
127
+ ast
128
+ );
129
+ if (missingMarkerDiagnostic) {
130
+ return [missingMarkerDiagnostic];
131
+ }
132
+
133
+ const markedWithoutIdsDiagnostic = draftMarkedWithoutIdsDiagnostic(
134
+ filePath,
135
+ ast
136
+ );
137
+ if (markedWithoutIdsDiagnostic) {
138
+ return [markedWithoutIdsDiagnostic];
139
+ }
140
+
141
+ return [];
142
+ };
143
+
144
+ /**
145
+ * Ensures files containing draft ids are visibly marked as draft-bearing files.
146
+ */
147
+ export const draftFileMarking: WardenRule = {
148
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
149
+ const ast = parse(filePath, sourceCode);
150
+ if (!ast) {
151
+ return [];
152
+ }
153
+
154
+ return collectDraftFileMarkingDiagnostics(sourceCode, filePath, ast);
155
+ },
156
+ description:
157
+ 'Require draft-bearing files to use _draft.* or *.draft.* filename markers.',
158
+ name: 'draft-file-marking',
159
+ severity: 'error',
160
+ };
@@ -0,0 +1,87 @@
1
+ import { isDraftId } from '@ontrails/core';
2
+
3
+ import {
4
+ collectFrameworkDraftPrefixConstantOffsets,
5
+ findStringLiterals,
6
+ hasIgnoreCommentOnLine,
7
+ offsetToLine,
8
+ parse,
9
+ splitSourceLines,
10
+ } from './ast.js';
11
+ import type { WardenDiagnostic, WardenRule } from './types.js';
12
+
13
+ const createDiagnostic = (
14
+ sourceCode: string,
15
+ filePath: string,
16
+ match: { start: number; value: string }
17
+ ): WardenDiagnostic => ({
18
+ filePath,
19
+ line: offsetToLine(sourceCode, match.start),
20
+ message:
21
+ `Draft id "${match.value}" is still visible debt. ` +
22
+ 'Established surfaces, lock export, and OpenAPI generation will reject it until it is promoted.',
23
+ rule: 'draft-visible-debt',
24
+ severity: 'warn',
25
+ });
26
+
27
+ const isSuppressedMatch = (
28
+ match: { start: number },
29
+ sourceCode: string,
30
+ lines: readonly string[],
31
+ frameworkConstantOffsets: ReadonlySet<number>
32
+ ): boolean =>
33
+ frameworkConstantOffsets.has(match.start) ||
34
+ hasIgnoreCommentOnLine(lines, offsetToLine(sourceCode, match.start));
35
+
36
+ const collectDraftVisibleDebtDiagnostics = (
37
+ sourceCode: string,
38
+ filePath: string,
39
+ ast: NonNullable<ReturnType<typeof parse>>
40
+ ): WardenDiagnostic[] => {
41
+ const frameworkConstantOffsets = collectFrameworkDraftPrefixConstantOffsets(
42
+ ast,
43
+ filePath
44
+ );
45
+ const lines = splitSourceLines(sourceCode);
46
+ const seen = new Set<string>();
47
+
48
+ return findStringLiterals(ast, (value) => isDraftId(value)).flatMap(
49
+ (match) => {
50
+ if (
51
+ isSuppressedMatch(match, sourceCode, lines, frameworkConstantOffsets)
52
+ ) {
53
+ return [];
54
+ }
55
+ const key = `${match.value}:${String(match.start)}`;
56
+ if (seen.has(key)) {
57
+ return [];
58
+ }
59
+ seen.add(key);
60
+ return [createDiagnostic(sourceCode, filePath, match)];
61
+ }
62
+ );
63
+ };
64
+
65
+ /**
66
+ * Warns when draft ids are still present so the debt stays visible during
67
+ * review even when the file is correctly marked.
68
+ *
69
+ * Severity is intentionally `warn`, not `error`. The hard rejection layer for
70
+ * draft state leaking into established outputs is `validateEstablishedTopo` at
71
+ * runtime — it blocks topo compile, surface projection, and lockfile writes.
72
+ * This rule surfaces the debt for human reviewers without duplicating that layer.
73
+ */
74
+ export const draftVisibleDebt: WardenRule = {
75
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
76
+ const ast = parse(filePath, sourceCode);
77
+ if (!ast) {
78
+ return [];
79
+ }
80
+
81
+ return collectDraftVisibleDebtDiagnostics(sourceCode, filePath, ast);
82
+ },
83
+ description:
84
+ 'Warn when draft ids remain in source so the debt stays visible during review.',
85
+ name: 'draft-visible-debt',
86
+ severity: 'warn',
87
+ };
@@ -0,0 +1,91 @@
1
+ import { deriveTopoGraph } from '@ontrails/topographer';
2
+ import type { TopoGraph, TopoGraphEntry } from '@ontrails/topographer';
3
+
4
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
5
+
6
+ const RULE_NAME = 'duplicate-public-contract';
7
+ const TOPO_FILE = '<topo>';
8
+
9
+ const resolveGraph = (
10
+ topo: Parameters<TopoAwareWardenRule['checkTopo']>[0],
11
+ graph: TopoGraph | undefined
12
+ ): TopoGraph => graph ?? deriveTopoGraph(topo);
13
+
14
+ const canonicalize = (value: unknown): unknown => {
15
+ if (Array.isArray(value)) {
16
+ return value.map(canonicalize);
17
+ }
18
+ if (value && typeof value === 'object') {
19
+ return Object.fromEntries(
20
+ Object.entries(value as Record<string, unknown>)
21
+ .filter(([, entryValue]) => entryValue !== undefined)
22
+ .toSorted(([left], [right]) => left.localeCompare(right))
23
+ .map(([key, entryValue]) => [key, canonicalize(entryValue)])
24
+ );
25
+ }
26
+ return value;
27
+ };
28
+
29
+ const contractFingerprint = (entry: TopoGraphEntry): string =>
30
+ JSON.stringify(
31
+ canonicalize({
32
+ composes: entry.composes,
33
+ detours: entry.detours,
34
+ dryRunCapable: entry.dryRunCapable,
35
+ fires: entry.fires,
36
+ idempotent: entry.idempotent,
37
+ input: entry.input,
38
+ intent: entry.intent,
39
+ meta: entry.meta,
40
+ on: entry.on,
41
+ output: entry.output,
42
+ permit: entry.permit,
43
+ resources: entry.resources,
44
+ })
45
+ );
46
+
47
+ const isCandidate = (entry: TopoGraphEntry): boolean =>
48
+ entry.kind === 'trail' &&
49
+ !entry.id.startsWith('warden.rule.') &&
50
+ entry.deprecated !== true &&
51
+ entry.meta?.['internal'] !== true &&
52
+ entry.input !== undefined &&
53
+ entry.output !== undefined &&
54
+ Boolean(entry.cli || entry.surfaces.length > 0);
55
+
56
+ const renderTrailIds = (trailIds: readonly string[]): string =>
57
+ trailIds
58
+ .toSorted((left, right) => left.localeCompare(right))
59
+ .map((trailId) => `"${trailId}"`)
60
+ .join(', ');
61
+
62
+ const buildDiagnostic = (trailIds: readonly string[]): WardenDiagnostic => ({
63
+ filePath: TOPO_FILE,
64
+ line: 1,
65
+ message: `Likely duplicate public trail contracts ${renderTrailIds(trailIds)} share the same input, output, intent, permits, resources, composes, signals, and detours. Keep one contract with aliases/input mappings, compose a distinct wrapper, or document why these public contracts are separate.`,
66
+ rule: RULE_NAME,
67
+ severity: 'warn',
68
+ });
69
+
70
+ export const duplicatePublicContract: TopoAwareWardenRule = {
71
+ checkTopo(topo, context) {
72
+ const graph = resolveGraph(topo, context?.graph);
73
+ const groups = new Map<string, string[]>();
74
+
75
+ for (const entry of graph.entries) {
76
+ if (!isCandidate(entry)) {
77
+ continue;
78
+ }
79
+ const key = contractFingerprint(entry);
80
+ groups.set(key, [...(groups.get(key) ?? []), entry.id]);
81
+ }
82
+
83
+ return [...groups.values()]
84
+ .filter((trailIds) => trailIds.length > 1)
85
+ .map(buildDiagnostic);
86
+ },
87
+ description:
88
+ 'Warn when public surface trails expose the same normalized contract facts.',
89
+ name: RULE_NAME,
90
+ severity: 'warn',
91
+ };