@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,179 @@
1
+ import { isDraftId } from '@ontrails/core';
2
+
3
+ import {
4
+ collectNamedResourceIds,
5
+ collectResourceDefinitionIds,
6
+ extractFirstStringArg,
7
+ findConfigProperty,
8
+ findTrailDefinitions,
9
+ getStringValue,
10
+ identifierName,
11
+ isStringLiteral,
12
+ offsetToLine,
13
+ parse,
14
+ } from './ast.js';
15
+ import type { AstNode } from './ast.js';
16
+ import { isTestFile } from './scan.js';
17
+ import type {
18
+ ProjectAwareWardenRule,
19
+ ProjectContext,
20
+ WardenDiagnostic,
21
+ } from './types.js';
22
+
23
+ const isResourceCall = (node: AstNode): boolean =>
24
+ node.type === 'CallExpression' &&
25
+ identifierName((node as unknown as { callee?: AstNode }).callee) ===
26
+ 'resource';
27
+
28
+ const getResourceElements = (config: AstNode): readonly AstNode[] => {
29
+ const resourcesProp = findConfigProperty(config, 'resources');
30
+ if (!resourcesProp) {
31
+ return [];
32
+ }
33
+
34
+ const arrayNode = resourcesProp.value;
35
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
36
+ return [];
37
+ }
38
+
39
+ const elements = (arrayNode as AstNode)['elements'] as
40
+ | readonly AstNode[]
41
+ | undefined;
42
+ return elements ?? [];
43
+ };
44
+
45
+ const extractDeclaredResourceId = (
46
+ element: AstNode,
47
+ resourceIdsByName: ReadonlyMap<string, string>
48
+ ): string | null => {
49
+ if (element.type === 'Identifier') {
50
+ const name = identifierName(element);
51
+ return name ? (resourceIdsByName.get(name) ?? null) : null;
52
+ }
53
+
54
+ if (isStringLiteral(element)) {
55
+ return getStringValue(element);
56
+ }
57
+
58
+ return isResourceCall(element) ? extractFirstStringArg(element) : null;
59
+ };
60
+
61
+ const extractDeclaredResourceIds = (
62
+ config: AstNode,
63
+ resourceIdsByName: ReadonlyMap<string, string>
64
+ ): readonly string[] => [
65
+ ...new Set(
66
+ getResourceElements(config).flatMap((element) => {
67
+ const id = extractDeclaredResourceId(element, resourceIdsByName);
68
+ return id ? [id] : [];
69
+ })
70
+ ),
71
+ ];
72
+
73
+ const buildMissingResourceDiagnostic = (
74
+ trailId: string,
75
+ resourceId: string,
76
+ filePath: string,
77
+ line: number
78
+ ): WardenDiagnostic => ({
79
+ filePath,
80
+ line,
81
+ message: `Trail "${trailId}" declares resource "${resourceId}" which is not defined in the project. Define it with resource('${resourceId}', ...) and ensure that definition is included in the topo.`,
82
+ rule: 'resource-exists',
83
+ severity: 'error',
84
+ });
85
+
86
+ const reportMissingResources = (
87
+ def: { id: string; config: AstNode; start: number },
88
+ sourceCode: string,
89
+ resourceIdsByName: ReadonlyMap<string, string>,
90
+ filePath: string,
91
+ knownResourceIds: ReadonlySet<string>,
92
+ diagnostics: WardenDiagnostic[]
93
+ ): void => {
94
+ const line = offsetToLine(sourceCode, def.start);
95
+ for (const resourceId of extractDeclaredResourceIds(
96
+ def.config,
97
+ resourceIdsByName
98
+ )) {
99
+ if (!knownResourceIds.has(resourceId) && !isDraftId(resourceId)) {
100
+ diagnostics.push(
101
+ buildMissingResourceDiagnostic(def.id, resourceId, filePath, line)
102
+ );
103
+ }
104
+ }
105
+ };
106
+
107
+ const buildResourceDiagnostics = (
108
+ ast: AstNode,
109
+ sourceCode: string,
110
+ filePath: string,
111
+ knownResourceIds: ReadonlySet<string>
112
+ ): readonly WardenDiagnostic[] => {
113
+ const diagnostics: WardenDiagnostic[] = [];
114
+ const resourceIdsByName = collectNamedResourceIds(ast);
115
+ for (const def of findTrailDefinitions(ast)) {
116
+ reportMissingResources(
117
+ def,
118
+ sourceCode,
119
+ resourceIdsByName,
120
+ filePath,
121
+ knownResourceIds,
122
+ diagnostics
123
+ );
124
+ }
125
+ return diagnostics;
126
+ };
127
+
128
+ const checkResourcesExist = (
129
+ sourceCode: string,
130
+ filePath: string,
131
+ knownResourceIds: ReadonlySet<string>
132
+ ): readonly WardenDiagnostic[] => {
133
+ if (isTestFile(filePath)) {
134
+ return [];
135
+ }
136
+
137
+ const ast = parse(filePath, sourceCode);
138
+ if (!ast) {
139
+ return [];
140
+ }
141
+
142
+ return buildResourceDiagnostics(ast, sourceCode, filePath, knownResourceIds);
143
+ };
144
+
145
+ /**
146
+ * Checks that all declared resources resolve to known resource definitions.
147
+ */
148
+ export const resourceExists: ProjectAwareWardenRule = {
149
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
150
+ const ast = parse(filePath, sourceCode);
151
+ if (!ast) {
152
+ return [];
153
+ }
154
+ return checkResourcesExist(
155
+ sourceCode,
156
+ filePath,
157
+ collectResourceDefinitionIds(ast)
158
+ );
159
+ },
160
+ checkWithContext(
161
+ sourceCode: string,
162
+ filePath: string,
163
+ context: ProjectContext
164
+ ): readonly WardenDiagnostic[] {
165
+ const ast = parse(filePath, sourceCode);
166
+ const localResourceIds = ast
167
+ ? collectResourceDefinitionIds(ast)
168
+ : new Set<string>();
169
+ return checkResourcesExist(
170
+ sourceCode,
171
+ filePath,
172
+ context.knownResourceIds ?? localResourceIds
173
+ );
174
+ },
175
+ description:
176
+ 'Ensure every resource declared on a trail resolves to a known resource definition.',
177
+ name: 'resource-exists',
178
+ severity: 'error',
179
+ };
@@ -0,0 +1,65 @@
1
+ import {
2
+ extractFirstStringArg,
3
+ identifierName,
4
+ offsetToLine,
5
+ parse,
6
+ walk,
7
+ } from './ast.js';
8
+ import type { AstNode } from './ast.js';
9
+ import { isTestFile } from './scan.js';
10
+ import type { WardenDiagnostic, WardenRule } from './types.js';
11
+
12
+ const isResourceCall = (node: AstNode): boolean =>
13
+ node.type === 'CallExpression' &&
14
+ identifierName((node as unknown as { callee?: AstNode }).callee) ===
15
+ 'resource';
16
+
17
+ const buildDiagnostic = (
18
+ resourceId: string,
19
+ filePath: string,
20
+ line: number
21
+ ): WardenDiagnostic => ({
22
+ filePath,
23
+ line,
24
+ message: `Resource "${resourceId}" is invalid because resource ids may not contain ":".`,
25
+ rule: 'resource-id-grammar',
26
+ severity: 'error',
27
+ });
28
+
29
+ export const resourceIdGrammar: WardenRule = {
30
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
31
+ if (isTestFile(filePath)) {
32
+ return [];
33
+ }
34
+
35
+ const ast = parse(filePath, sourceCode);
36
+ if (!ast) {
37
+ return [];
38
+ }
39
+
40
+ const diagnostics: WardenDiagnostic[] = [];
41
+ walk(ast, (node) => {
42
+ if (!isResourceCall(node)) {
43
+ return;
44
+ }
45
+
46
+ const resourceId = extractFirstStringArg(node);
47
+ if (!resourceId || !resourceId.includes(':')) {
48
+ return;
49
+ }
50
+
51
+ diagnostics.push(
52
+ buildDiagnostic(
53
+ resourceId,
54
+ filePath,
55
+ offsetToLine(sourceCode, node.start)
56
+ )
57
+ );
58
+ });
59
+
60
+ return diagnostics;
61
+ },
62
+ description: 'Ensure resource ids do not contain the ":" scope separator.',
63
+ name: 'resource-id-grammar',
64
+ severity: 'error',
65
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Warns when a `resource('id', { ... })` definition declares neither a `mock`
3
+ * factory nor an explicit `unmockable` reason.
4
+ *
5
+ * Every resource should declare its test posture: a `mock` factory so
6
+ * `testAll(app)` runs without production-like configuration (common pitfall
7
+ * #10), or an explicit `unmockable: { reason }` escape hatch when it genuinely
8
+ * cannot be mocked. The `mock?`/`unmockable?` fields are both optional in
9
+ * `ResourceSpec`, so the compiler does not enforce the choice — this rule does.
10
+ *
11
+ * Conservative by design (zero false positives over completeness): only flags
12
+ * a `resource()` call whose second argument is an inline object literal with no
13
+ * spread. A referenced spec variable, a spread spec, or a non-object second
14
+ * argument cannot be verified statically, so they are skipped.
15
+ */
16
+
17
+ import {
18
+ extractFirstStringArg,
19
+ findConfigProperty,
20
+ identifierName,
21
+ offsetToLine,
22
+ parse,
23
+ walk,
24
+ } from './ast.js';
25
+ import type { AstNode } from './ast.js';
26
+ import { isFrameworkInternalFile, isTestFile } from './scan.js';
27
+ import type { WardenDiagnostic, WardenRule } from './types.js';
28
+
29
+ const isResourceCall = (node: AstNode): boolean =>
30
+ node.type === 'CallExpression' &&
31
+ identifierName((node as unknown as { callee?: AstNode }).callee) ===
32
+ 'resource';
33
+
34
+ /**
35
+ * `.test-d.ts` type-fixture files are not matched by `isTestFile` (its pattern
36
+ * keys on `.test.`/`.spec.`), yet they hold type-inference probe resources that
37
+ * intentionally omit `mock`. Treat them as test fixtures here.
38
+ */
39
+ const isTypeFixtureFile = (filePath: string): boolean =>
40
+ filePath.endsWith('.test-d.ts') || filePath.endsWith('.test-d.tsx');
41
+
42
+ /**
43
+ * Framework-internal packages (`@ontrails/warden`, `@ontrails/testing`) define
44
+ * throwaway fixture resources to build example topos for other rules' tests
45
+ * (e.g. signal-graph-coaching's `invoiceStore`). Those scaffolding resources
46
+ * are not governed application resources, so skip them — consistent with how
47
+ * the rest of the framework treats `isFrameworkInternalFile` source.
48
+ */
49
+ const isExcludedFile = (filePath: string): boolean =>
50
+ isTestFile(filePath) ||
51
+ isTypeFixtureFile(filePath) ||
52
+ isFrameworkInternalFile(filePath);
53
+
54
+ /** A spec object literal we can analyze: an ObjectExpression with no spread. */
55
+ const isStaticallyAnalyzableSpec = (spec: AstNode | undefined): boolean => {
56
+ if (!spec || spec.type !== 'ObjectExpression') {
57
+ return false;
58
+ }
59
+ const properties = spec['properties'] as readonly AstNode[] | undefined;
60
+ if (!properties) {
61
+ return false;
62
+ }
63
+ // A spread (`...base`) could contribute `mock`/`unmockable` from elsewhere;
64
+ // we cannot prove its absence, so do not flag.
65
+ return properties.every((prop) => prop.type === 'Property');
66
+ };
67
+
68
+ const declaresTestPosture = (spec: AstNode): boolean =>
69
+ findConfigProperty(spec, 'mock') !== null ||
70
+ findConfigProperty(spec, 'unmockable') !== null;
71
+
72
+ export const resourceMockCoverage: WardenRule = {
73
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
74
+ if (isExcludedFile(filePath)) {
75
+ return [];
76
+ }
77
+
78
+ const ast = parse(filePath, sourceCode);
79
+ if (!ast) {
80
+ return [];
81
+ }
82
+
83
+ const diagnostics: WardenDiagnostic[] = [];
84
+ walk(ast, (node) => {
85
+ if (!isResourceCall(node)) {
86
+ return;
87
+ }
88
+
89
+ const args = node['arguments'] as readonly AstNode[] | undefined;
90
+ const spec = args?.[1];
91
+ if (!isStaticallyAnalyzableSpec(spec) || !spec) {
92
+ return;
93
+ }
94
+ if (declaresTestPosture(spec)) {
95
+ return;
96
+ }
97
+
98
+ const resourceId = extractFirstStringArg(node);
99
+ const subject = resourceId ? `Resource "${resourceId}"` : 'Resource';
100
+ diagnostics.push({
101
+ filePath,
102
+ line: offsetToLine(sourceCode, node.start),
103
+ message: `${subject} declares no mock factory. Add a mock() so testAll(app) runs without configuration, or declare unmockable: { reason } if it intentionally cannot be mocked.`,
104
+ rule: 'resource-mock-coverage',
105
+ severity: 'warn',
106
+ });
107
+ });
108
+
109
+ return diagnostics;
110
+ },
111
+ description:
112
+ 'Resource definitions declare a mock factory or an explicit unmockable reason.',
113
+ name: 'resource-mock-coverage',
114
+ severity: 'warn',
115
+ };
package/src/rules/scan.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  const TEST_FILE_PATTERN =
2
2
  /(?:^|\/)__tests__(?:\/|$)|(?:\.test|\.spec)\.[cm]?[jt]sx?$/;
3
3
 
4
+ // The CLI scan-target contract also recognizes a singular `__test__` directory.
5
+ // That compatibility stays scoped to the root-relative scan helpers: it must not
6
+ // reach the absolute-path `isTestFile` rule predicate, where an ancestor
7
+ // directory named `__test__` would otherwise misclassify every source file.
8
+ const SCAN_TARGET_TEST_FILE_PATTERN =
9
+ /(?:^|\/)__tests?__(?:\/|$)|(?:\.test|\.spec)\.[cm]?[jt]sx?$/;
10
+
4
11
  const FRAMEWORK_INTERNAL_SEGMENTS = [
5
12
  '/packages/testing/',
6
13
  '/packages/warden/',
@@ -9,38 +16,44 @@ const FRAMEWORK_INTERNAL_SEGMENTS = [
9
16
  const normalizeFilePath = (filePath: string): string =>
10
17
  filePath.replaceAll('\\', '/');
11
18
 
12
- const maskText = (text: string): string => text.replaceAll(/[^\n]/g, ' ');
13
-
14
- const stripPattern = (sourceCode: string, pattern: RegExp): string =>
15
- sourceCode.replaceAll(pattern, (match) => maskText(match));
19
+ const toRootRelativeScanPath = (filePath: string): string =>
20
+ normalizeFilePath(filePath).replace(/^\.\//, '');
16
21
 
17
22
  export const isTestFile = (filePath: string): boolean =>
18
23
  TEST_FILE_PATTERN.test(normalizeFilePath(filePath));
19
24
 
20
- export const isFrameworkInternalFile = (filePath: string): boolean => {
21
- const normalized = normalizeFilePath(filePath);
22
- return FRAMEWORK_INTERNAL_SEGMENTS.some((segment) =>
23
- normalized.includes(segment)
25
+ export const isWardenTestScanTarget = (filePath: string): boolean =>
26
+ SCAN_TARGET_TEST_FILE_PATTERN.test(toRootRelativeScanPath(filePath));
27
+
28
+ export const isWardenInfrastructureScanTarget = (filePath: string): boolean => {
29
+ const match = toRootRelativeScanPath(filePath);
30
+ return (
31
+ match.endsWith('.d.ts') ||
32
+ match.startsWith('node_modules/') ||
33
+ match.startsWith('dist/') ||
34
+ match.startsWith('.git/')
24
35
  );
25
36
  };
26
37
 
27
38
  /**
28
- * Replace quoted content and comments with whitespace while preserving line
29
- * breaks so simple line-based scanners do not match examples or messages.
39
+ * Whether a root-relative path should receive Warden committed-source checks.
40
+ *
41
+ * Warden's CLI glob runner passes root-relative matches here. Consumers that
42
+ * already have a root-relative source path should use the same helper before
43
+ * invoking Warden-owned rules directly so diagnostics do not drift from the
44
+ * CLI runner's scan target contract.
30
45
  */
31
- export const stripQuotedContent = (sourceCode: string): string => {
32
- let sanitized = sourceCode;
33
- const patterns = [
34
- /\/\/[^\n]*/g,
35
- /\/\*[\s\S]*?\*\//g,
36
- /'[^'\\\n]*(?:\\.[^'\\\n]*)*'/g,
37
- /"[^"\\\n]*(?:\\.[^"\\\n]*)*"/g,
38
- /`[\s\S]*?`/g,
39
- ];
40
-
41
- for (const pattern of patterns) {
42
- sanitized = stripPattern(sanitized, pattern);
43
- }
44
-
45
- return sanitized;
46
+ export const isWardenSourceScanTarget = (filePath: string): boolean =>
47
+ !isWardenInfrastructureScanTarget(filePath) &&
48
+ !isWardenTestScanTarget(filePath);
49
+
50
+ export const isWardenDevPermitTestScanTarget = (filePath: string): boolean =>
51
+ !isWardenInfrastructureScanTarget(filePath) &&
52
+ isWardenTestScanTarget(filePath);
53
+
54
+ export const isFrameworkInternalFile = (filePath: string): boolean => {
55
+ const normalized = normalizeFilePath(filePath);
56
+ return FRAMEWORK_INTERNAL_SEGMENTS.some((segment) =>
57
+ normalized.includes(segment)
58
+ );
46
59
  };
@@ -0,0 +1,44 @@
1
+ import type { AnyTrail } from '@ontrails/core';
2
+
3
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
4
+
5
+ const RULE_NAME = 'scheduled-destroy-intent';
6
+ const TOPO_FILE = '<topo>';
7
+
8
+ const isScheduleActivated = (trail: AnyTrail): boolean =>
9
+ trail.activationSources.some(
10
+ (activation) => activation.source.kind === 'schedule'
11
+ );
12
+
13
+ const scheduleSourceIds = (trail: AnyTrail): readonly string[] => [
14
+ ...new Set(
15
+ trail.activationSources.flatMap((activation) =>
16
+ activation.source.kind === 'schedule' ? [activation.source.id] : []
17
+ )
18
+ ),
19
+ ];
20
+
21
+ const buildDiagnostic = (
22
+ trail: AnyTrail,
23
+ sourceIds: readonly string[]
24
+ ): WardenDiagnostic => ({
25
+ filePath: TOPO_FILE,
26
+ line: 1,
27
+ message: `Trail "${trail.id}" declares intent: 'destroy' and is activated by schedule source${sourceIds.length === 1 ? '' : 's'} ${sourceIds.map((id) => `"${id}"`).join(', ')}. Scheduled destroy work should make cadence, permit scope, idempotency, and recovery explicit before it runs unattended.`,
28
+ rule: RULE_NAME,
29
+ severity: 'warn',
30
+ });
31
+
32
+ export const scheduledDestroyIntent: TopoAwareWardenRule = {
33
+ checkTopo: (topo) =>
34
+ topo
35
+ .list()
36
+ .filter(
37
+ (trail) => trail.intent === 'destroy' && isScheduleActivated(trail)
38
+ )
39
+ .map((trail) => buildDiagnostic(trail, scheduleSourceIds(trail))),
40
+ description:
41
+ 'Warn when destroy-intent trails are activated by schedule sources.',
42
+ name: RULE_NAME,
43
+ severity: 'warn',
44
+ };
@@ -0,0 +1,191 @@
1
+ import type { Topo } from '@ontrails/core';
2
+
3
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
4
+
5
+ const RULE_NAME = 'signal-graph-coaching';
6
+ const TOPO_FILE = '<topo>';
7
+
8
+ interface SignalRelations {
9
+ readonly consumers: readonly string[];
10
+ readonly producerResources: readonly string[];
11
+ readonly producerTrails: readonly string[];
12
+ }
13
+
14
+ const sortedUnique = (values: Iterable<string>): readonly string[] =>
15
+ [...new Set(values)].toSorted();
16
+
17
+ const collectSignalIds = (topo: Topo): readonly string[] =>
18
+ sortedUnique(topo.listSignals().map((signal) => signal.id));
19
+
20
+ const collectProducerTrails = (
21
+ topo: Topo
22
+ ): ReadonlyMap<string, readonly string[]> => {
23
+ const producersBySignal = new Map<string, Set<string>>();
24
+
25
+ for (const signal of topo.listSignals()) {
26
+ if ((signal.from?.length ?? 0) === 0) {
27
+ continue;
28
+ }
29
+ const producers = producersBySignal.get(signal.id) ?? new Set<string>();
30
+ for (const producerTrailId of signal.from ?? []) {
31
+ producers.add(producerTrailId);
32
+ }
33
+ producersBySignal.set(signal.id, producers);
34
+ }
35
+
36
+ for (const trail of topo.list()) {
37
+ for (const signalId of trail.fires) {
38
+ const producers = producersBySignal.get(signalId) ?? new Set<string>();
39
+ producers.add(trail.id);
40
+ producersBySignal.set(signalId, producers);
41
+ }
42
+ }
43
+
44
+ return new Map(
45
+ [...producersBySignal.entries()].map(([signalId, producers]) => [
46
+ signalId,
47
+ sortedUnique(producers),
48
+ ])
49
+ );
50
+ };
51
+
52
+ const collectProducerResources = (
53
+ topo: Topo
54
+ ): ReadonlyMap<string, readonly string[]> => {
55
+ const resourcesBySignal = new Map<string, Set<string>>();
56
+
57
+ for (const resource of topo.listResources()) {
58
+ for (const signal of resource.signals ?? []) {
59
+ const resources = resourcesBySignal.get(signal.id) ?? new Set<string>();
60
+ resources.add(resource.id);
61
+ resourcesBySignal.set(signal.id, resources);
62
+ }
63
+ }
64
+
65
+ return new Map(
66
+ [...resourcesBySignal.entries()].map(([signalId, resources]) => [
67
+ signalId,
68
+ sortedUnique(resources),
69
+ ])
70
+ );
71
+ };
72
+
73
+ const collectConsumers = (
74
+ topo: Topo
75
+ ): ReadonlyMap<string, readonly string[]> => {
76
+ const consumersBySignal = new Map<string, Set<string>>();
77
+
78
+ for (const trail of topo.list()) {
79
+ for (const activation of trail.activationSources) {
80
+ if (activation.source.kind !== 'signal') {
81
+ continue;
82
+ }
83
+ const consumers =
84
+ consumersBySignal.get(activation.source.id) ?? new Set<string>();
85
+ consumers.add(trail.id);
86
+ consumersBySignal.set(activation.source.id, consumers);
87
+ }
88
+ }
89
+
90
+ return new Map(
91
+ [...consumersBySignal.entries()].map(([signalId, consumers]) => [
92
+ signalId,
93
+ sortedUnique(consumers),
94
+ ])
95
+ );
96
+ };
97
+
98
+ const collectRelations = (topo: Topo): ReadonlyMap<string, SignalRelations> => {
99
+ const producerTrails = collectProducerTrails(topo);
100
+ const producerResources = collectProducerResources(topo);
101
+ const consumers = collectConsumers(topo);
102
+
103
+ return new Map(
104
+ collectSignalIds(topo).map((signalId) => [
105
+ signalId,
106
+ {
107
+ consumers: consumers.get(signalId) ?? [],
108
+ producerResources: producerResources.get(signalId) ?? [],
109
+ producerTrails: producerTrails.get(signalId) ?? [],
110
+ },
111
+ ])
112
+ );
113
+ };
114
+
115
+ const quoteList = (values: readonly string[]): string =>
116
+ values.map((value) => `"${value}"`).join(', ');
117
+
118
+ const formatProducerClause = ({
119
+ producerResources,
120
+ producerTrails,
121
+ }: SignalRelations): string => {
122
+ const clauses: string[] = [];
123
+ if (producerTrails.length > 0) {
124
+ clauses.push(
125
+ `producer trail${producerTrails.length === 1 ? '' : 's'} ${quoteList(producerTrails)}`
126
+ );
127
+ }
128
+ if (producerResources.length > 0) {
129
+ clauses.push(
130
+ `producer resource${producerResources.length === 1 ? '' : 's'} ${quoteList(producerResources)}`
131
+ );
132
+ }
133
+ return clauses.join(' and ');
134
+ };
135
+
136
+ const buildDeadSignalDiagnostic = (signalId: string): WardenDiagnostic => ({
137
+ filePath: TOPO_FILE,
138
+ line: 1,
139
+ message: `Signal "${signalId}" is declared in the topo but has no producer trails, producer resources, or consumer trails. Add fires:/on: edges, attach producer metadata, or remove the unused signal contract.`,
140
+ rule: RULE_NAME,
141
+ severity: 'warn',
142
+ });
143
+
144
+ const buildProducedWithoutConsumerDiagnostic = (
145
+ signalId: string,
146
+ relations: SignalRelations
147
+ ): WardenDiagnostic => ({
148
+ filePath: TOPO_FILE,
149
+ line: 1,
150
+ message: `Signal "${signalId}" is produced by ${formatProducerClause(relations)} but has no consumer trails. Add an on: consumer if the signal is meant to drive reactive work, or remove the unused fires:/producer declaration.`,
151
+ rule: RULE_NAME,
152
+ severity: 'warn',
153
+ });
154
+
155
+ const hasProducer = ({
156
+ producerResources,
157
+ producerTrails,
158
+ }: SignalRelations): boolean =>
159
+ producerResources.length > 0 || producerTrails.length > 0;
160
+
161
+ const hasConsumer = ({ consumers }: SignalRelations): boolean =>
162
+ consumers.length > 0;
163
+
164
+ const buildDiagnostics = (
165
+ relationsBySignal: ReadonlyMap<string, SignalRelations>
166
+ ): readonly WardenDiagnostic[] => {
167
+ const diagnostics: WardenDiagnostic[] = [];
168
+
169
+ for (const [signalId, relations] of relationsBySignal) {
170
+ if (!hasProducer(relations) && !hasConsumer(relations)) {
171
+ diagnostics.push(buildDeadSignalDiagnostic(signalId));
172
+ continue;
173
+ }
174
+
175
+ if (hasProducer(relations) && !hasConsumer(relations)) {
176
+ diagnostics.push(
177
+ buildProducedWithoutConsumerDiagnostic(signalId, relations)
178
+ );
179
+ }
180
+ }
181
+
182
+ return diagnostics;
183
+ };
184
+
185
+ export const signalGraphCoaching: TopoAwareWardenRule = {
186
+ checkTopo: (topo) => buildDiagnostics(collectRelations(topo)),
187
+ description:
188
+ 'Warn when typed signal contracts are declared or produced without reactive consumers.',
189
+ name: RULE_NAME,
190
+ severity: 'warn',
191
+ };