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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +835 -13
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +27 -6
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +1570 -105
  7. package/src/command.ts +986 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +233 -23
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +78 -13
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +247 -15
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/resolve.ts +531 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/captured-kernel.ts +375 -0
  20. package/src/rules/circular-refs.ts +150 -0
  21. package/src/rules/cli-command-route-coherence.ts +177 -0
  22. package/src/rules/composes-declarations.ts +839 -0
  23. package/src/rules/context-no-surface-types.ts +79 -15
  24. package/src/rules/dead-internal-trail.ts +161 -0
  25. package/src/rules/dead-public-trail.ts +258 -0
  26. package/src/rules/draft-file-marking.ts +155 -0
  27. package/src/rules/draft-visible-debt.ts +82 -0
  28. package/src/rules/duplicate-exported-symbol.ts +211 -0
  29. package/src/rules/duplicate-public-contract.ts +137 -0
  30. package/src/rules/entity-exists.ts +254 -0
  31. package/src/rules/entity-ids.ts +15 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +740 -0
  35. package/src/rules/governed-symbol-residue.ts +438 -0
  36. package/src/rules/implementation-returns-result.ts +1409 -166
  37. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  38. package/src/rules/incomplete-crud.ts +583 -0
  39. package/src/rules/index.ts +277 -10
  40. package/src/rules/intent-propagation.ts +125 -0
  41. package/src/rules/layer-field-name-drift.ts +102 -0
  42. package/src/rules/library-projection-coherence.ts +100 -0
  43. package/src/rules/metadata.ts +871 -0
  44. package/src/rules/missing-reconcile.ts +97 -0
  45. package/src/rules/missing-visibility.ts +111 -0
  46. package/src/rules/no-destructured-compose.ts +196 -0
  47. package/src/rules/no-dev-permit-in-source.ts +99 -0
  48. package/src/rules/no-direct-implementation-call.ts +12 -7
  49. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  50. package/src/rules/no-legacy-layer-imports.ts +211 -0
  51. package/src/rules/no-native-error-result.ts +118 -0
  52. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  53. package/src/rules/no-retired-cross-vocabulary.ts +204 -0
  54. package/src/rules/no-sync-result-assumption.ts +1141 -98
  55. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  56. package/src/rules/no-throw-in-implementation.ts +15 -8
  57. package/src/rules/no-top-level-surface.ts +371 -0
  58. package/src/rules/on-references-exist.ts +194 -0
  59. package/src/rules/orphaned-signal.ts +149 -0
  60. package/src/rules/owner-projection-parity.ts +143 -0
  61. package/src/rules/permit-governance.ts +25 -0
  62. package/src/rules/public-export-example-coverage.ts +573 -0
  63. package/src/rules/public-internal-deep-imports.ts +456 -0
  64. package/src/rules/public-output-schema.ts +29 -0
  65. package/src/rules/public-union-output-discriminants.ts +150 -0
  66. package/src/rules/read-intent-fires.ts +188 -0
  67. package/src/rules/reference-exists.ts +97 -0
  68. package/src/rules/registry-names.ts +167 -0
  69. package/src/rules/resolved-import-boundary.ts +146 -0
  70. package/src/rules/resource-declarations.ts +697 -0
  71. package/src/rules/resource-exists.ts +181 -0
  72. package/src/rules/resource-id-grammar.ts +65 -0
  73. package/src/rules/resource-mock-coverage.ts +115 -0
  74. package/src/rules/retired-vocabulary.ts +633 -0
  75. package/src/rules/scan.ts +38 -25
  76. package/src/rules/scheduled-destroy-intent.ts +44 -0
  77. package/src/rules/signal-graph-coaching.ts +220 -0
  78. package/src/rules/source/composition.ts +165 -0
  79. package/src/rules/source/drafts.ts +164 -0
  80. package/src/rules/source/entities.ts +618 -0
  81. package/src/rules/source/pragmas.ts +45 -0
  82. package/src/rules/source/resources.ts +64 -0
  83. package/src/rules/source/signals.ts +397 -0
  84. package/src/rules/source/stores.ts +310 -0
  85. package/src/rules/specs.ts +9 -5
  86. package/src/rules/static-resource-accessor-preference.ts +653 -0
  87. package/src/rules/surface-overlay-coherence.ts +262 -0
  88. package/src/rules/surface-trailhead-coherence.ts +366 -0
  89. package/src/rules/trail-fork-coaching.ts +625 -0
  90. package/src/rules/trail-versioning-source.ts +1076 -0
  91. package/src/rules/trail-versioning-topo.ts +172 -0
  92. package/src/rules/trailhead-override-divergence.ts +356 -0
  93. package/src/rules/types.ts +354 -8
  94. package/src/rules/unmaterialized-activation-source.ts +85 -0
  95. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  96. package/src/rules/valid-describe-refs.ts +162 -32
  97. package/src/rules/valid-detour-contract.ts +78 -0
  98. package/src/rules/warden-export-symmetry.ts +540 -0
  99. package/src/rules/warden-rules-use-ast.ts +1109 -0
  100. package/src/rules/webhook-route-collision.ts +306 -0
  101. package/src/trails/activation-orphan.trail.ts +84 -0
  102. package/src/trails/captured-kernel.trail.ts +108 -0
  103. package/src/trails/circular-refs.trail.ts +29 -0
  104. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  105. package/src/trails/composes-declarations.trail.ts +22 -0
  106. package/src/trails/context-no-surface-types.trail.ts +21 -0
  107. package/src/trails/dead-internal-trail.trail.ts +26 -0
  108. package/src/trails/dead-public-trail.trail.ts +31 -0
  109. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  110. package/src/trails/draft-file-marking.trail.ts +16 -0
  111. package/src/trails/draft-visible-debt.trail.ts +16 -0
  112. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  113. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  114. package/src/trails/entity-exists.trail.ts +21 -0
  115. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  116. package/src/trails/example-valid.trail.ts +25 -0
  117. package/src/trails/fires-declarations.trail.ts +23 -0
  118. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  119. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  120. package/src/trails/implementation-returns-result.trail.ts +20 -0
  121. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  122. package/src/trails/incomplete-crud.trail.ts +39 -0
  123. package/src/trails/index.ts +89 -0
  124. package/src/trails/intent-propagation.trail.ts +30 -0
  125. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  126. package/src/trails/library-projection-coherence.trail.ts +43 -0
  127. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  128. package/src/trails/missing-reconcile.trail.ts +33 -0
  129. package/src/trails/missing-visibility.trail.ts +22 -0
  130. package/src/trails/no-destructured-compose.trail.ts +44 -0
  131. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  132. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  133. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  134. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  135. package/src/trails/no-native-error-result.trail.ts +18 -0
  136. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  137. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  138. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  139. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  140. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  141. package/src/trails/no-top-level-surface.trail.ts +43 -0
  142. package/src/trails/on-references-exist.trail.ts +21 -0
  143. package/src/trails/orphaned-signal.trail.ts +36 -0
  144. package/src/trails/owner-projection-parity.trail.ts +26 -0
  145. package/src/trails/pending-force.trail.ts +21 -0
  146. package/src/trails/permit-governance.trail.ts +51 -0
  147. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  148. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  149. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  150. package/src/trails/public-output-schema.trail.ts +55 -0
  151. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  152. package/src/trails/read-intent-fires.trail.ts +20 -0
  153. package/src/trails/reference-exists.trail.ts +25 -0
  154. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  155. package/src/trails/resource-declarations.trail.ts +25 -0
  156. package/src/trails/resource-exists.trail.ts +27 -0
  157. package/src/trails/resource-id-grammar.trail.ts +39 -0
  158. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  159. package/src/trails/run.ts +160 -0
  160. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  161. package/src/trails/schema.ts +237 -0
  162. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  163. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  164. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  165. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  166. package/src/trails/topo.ts +6 -0
  167. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  168. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  169. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  170. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  171. package/src/trails/valid-describe-refs.trail.ts +18 -0
  172. package/src/trails/valid-detour-contract.trail.ts +71 -0
  173. package/src/trails/version-gap.trail.ts +35 -0
  174. package/src/trails/version-pinned-compose.trail.ts +23 -0
  175. package/src/trails/version-without-examples.trail.ts +38 -0
  176. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  177. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  178. package/src/trails/webhook-route-collision.trail.ts +50 -0
  179. package/src/trails/wrap-rule.ts +224 -0
  180. package/src/workspaces.ts +199 -0
  181. package/.turbo/turbo-build.log +0 -1
  182. package/.turbo/turbo-lint.log +0 -3
  183. package/.turbo/turbo-typecheck.log +0 -1
  184. package/dist/cli.d.ts +0 -46
  185. package/dist/cli.d.ts.map +0 -1
  186. package/dist/cli.js +0 -218
  187. package/dist/cli.js.map +0 -1
  188. package/dist/drift.d.ts +0 -26
  189. package/dist/drift.d.ts.map +0 -1
  190. package/dist/drift.js +0 -27
  191. package/dist/drift.js.map +0 -1
  192. package/dist/formatters.d.ts +0 -29
  193. package/dist/formatters.d.ts.map +0 -1
  194. package/dist/formatters.js +0 -87
  195. package/dist/formatters.js.map +0 -1
  196. package/dist/index.d.ts +0 -26
  197. package/dist/index.d.ts.map +0 -1
  198. package/dist/index.js +0 -26
  199. package/dist/index.js.map +0 -1
  200. package/dist/rules/ast.d.ts +0 -41
  201. package/dist/rules/ast.d.ts.map +0 -1
  202. package/dist/rules/ast.js +0 -161
  203. package/dist/rules/ast.js.map +0 -1
  204. package/dist/rules/context-no-surface-types.d.ts +0 -12
  205. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  206. package/dist/rules/context-no-surface-types.js +0 -96
  207. package/dist/rules/context-no-surface-types.js.map +0 -1
  208. package/dist/rules/implementation-returns-result.d.ts +0 -13
  209. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  210. package/dist/rules/implementation-returns-result.js +0 -277
  211. package/dist/rules/implementation-returns-result.js.map +0 -1
  212. package/dist/rules/index.d.ts +0 -15
  213. package/dist/rules/index.d.ts.map +0 -1
  214. package/dist/rules/index.js +0 -34
  215. package/dist/rules/index.js.map +0 -1
  216. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  217. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  218. package/dist/rules/no-direct-impl-in-route.js +0 -47
  219. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  220. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  221. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  222. package/dist/rules/no-direct-implementation-call.js +0 -39
  223. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  224. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  225. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  226. package/dist/rules/no-sync-result-assumption.js +0 -98
  227. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  228. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  229. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  230. package/dist/rules/no-throw-in-detour-target.js +0 -87
  231. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  232. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  233. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  234. package/dist/rules/no-throw-in-implementation.js +0 -34
  235. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  236. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  237. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  238. package/dist/rules/prefer-schema-inference.js +0 -86
  239. package/dist/rules/prefer-schema-inference.js.map +0 -1
  240. package/dist/rules/scan.d.ts +0 -8
  241. package/dist/rules/scan.d.ts.map +0 -1
  242. package/dist/rules/scan.js +0 -32
  243. package/dist/rules/scan.js.map +0 -1
  244. package/dist/rules/specs.d.ts +0 -29
  245. package/dist/rules/specs.d.ts.map +0 -1
  246. package/dist/rules/specs.js +0 -192
  247. package/dist/rules/specs.js.map +0 -1
  248. package/dist/rules/structure.d.ts +0 -13
  249. package/dist/rules/structure.d.ts.map +0 -1
  250. package/dist/rules/structure.js +0 -142
  251. package/dist/rules/structure.js.map +0 -1
  252. package/dist/rules/types.d.ts +0 -52
  253. package/dist/rules/types.d.ts.map +0 -1
  254. package/dist/rules/types.js +0 -2
  255. package/dist/rules/types.js.map +0 -1
  256. package/dist/rules/valid-describe-refs.d.ts +0 -7
  257. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  258. package/dist/rules/valid-describe-refs.js +0 -51
  259. package/dist/rules/valid-describe-refs.js.map +0 -1
  260. package/dist/rules/valid-detour-refs.d.ts +0 -6
  261. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  262. package/dist/rules/valid-detour-refs.js +0 -116
  263. package/dist/rules/valid-detour-refs.js.map +0 -1
  264. package/src/__tests__/cli.test.ts +0 -198
  265. package/src/__tests__/drift.test.ts +0 -74
  266. package/src/__tests__/formatters.test.ts +0 -157
  267. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  268. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  269. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  270. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  271. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  272. package/src/__tests__/rules.test.ts +0 -227
  273. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  274. package/src/rules/ast.ts +0 -213
  275. package/src/rules/no-direct-impl-in-route.ts +0 -81
  276. package/src/rules/no-throw-in-detour-target.ts +0 -150
  277. package/src/rules/valid-detour-refs.ts +0 -187
  278. package/tsconfig.json +0 -9
  279. package/tsconfig.tsbuildinfo +0 -1
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,220 @@
1
+ import { getLateBoundSignalRef } from '@ontrails/core';
2
+ import type { Topo } from '@ontrails/core';
3
+
4
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
5
+
6
+ const RULE_NAME = 'signal-graph-coaching';
7
+ const TOPO_FILE = '<topo>';
8
+
9
+ interface SignalRelations {
10
+ readonly consumers: readonly string[];
11
+ readonly producerResources: readonly string[];
12
+ readonly producerTrails: readonly string[];
13
+ }
14
+
15
+ const sortedUnique = (values: Iterable<string>): readonly string[] =>
16
+ [...new Set(values)].toSorted();
17
+
18
+ const collectSignalIds = (topo: Topo): readonly string[] =>
19
+ sortedUnique(topo.listSignals().map((signal) => signal.id));
20
+
21
+ const collectProducerTrails = (
22
+ topo: Topo
23
+ ): ReadonlyMap<string, readonly string[]> => {
24
+ const producersBySignal = new Map<string, Set<string>>();
25
+
26
+ for (const signal of topo.listSignals()) {
27
+ if ((signal.from?.length ?? 0) === 0) {
28
+ continue;
29
+ }
30
+ const producers = producersBySignal.get(signal.id) ?? new Set<string>();
31
+ for (const producerTrailId of signal.from ?? []) {
32
+ producers.add(producerTrailId);
33
+ }
34
+ producersBySignal.set(signal.id, producers);
35
+ }
36
+
37
+ for (const trail of topo.list()) {
38
+ for (const signalId of trail.fires) {
39
+ const producers = producersBySignal.get(signalId) ?? new Set<string>();
40
+ producers.add(trail.id);
41
+ producersBySignal.set(signalId, producers);
42
+ }
43
+ }
44
+
45
+ return new Map(
46
+ [...producersBySignal.entries()].map(([signalId, producers]) => [
47
+ signalId,
48
+ sortedUnique(producers),
49
+ ])
50
+ );
51
+ };
52
+
53
+ const collectProducerResources = (
54
+ topo: Topo
55
+ ): ReadonlyMap<string, readonly string[]> => {
56
+ const resourcesBySignal = new Map<string, Set<string>>();
57
+
58
+ for (const resource of topo.listResources()) {
59
+ for (const signal of resource.signals ?? []) {
60
+ const resources = resourcesBySignal.get(signal.id) ?? new Set<string>();
61
+ resources.add(resource.id);
62
+ resourcesBySignal.set(signal.id, resources);
63
+ }
64
+ }
65
+
66
+ return new Map(
67
+ [...resourcesBySignal.entries()].map(([signalId, resources]) => [
68
+ signalId,
69
+ sortedUnique(resources),
70
+ ])
71
+ );
72
+ };
73
+
74
+ /**
75
+ * Signal ids whose contracts are store-derived advertisements.
76
+ *
77
+ * @remarks
78
+ * Store resources advertise `created`/`updated`/`removed` signals for every
79
+ * table as available capability. Leaving them unconsumed is a legitimate
80
+ * steady state, so produced-without-consumer coaching would be pure noise for
81
+ * store-backed apps.
82
+ */
83
+ const collectStoreDerivedSignalIds = (topo: Topo): ReadonlySet<string> =>
84
+ new Set(
85
+ topo
86
+ .listSignals()
87
+ .filter(
88
+ (signal) => getLateBoundSignalRef(signal)?.kind === 'store-derived'
89
+ )
90
+ .map((signal) => signal.id)
91
+ );
92
+
93
+ const collectConsumers = (
94
+ topo: Topo
95
+ ): ReadonlyMap<string, readonly string[]> => {
96
+ const consumersBySignal = new Map<string, Set<string>>();
97
+
98
+ for (const trail of topo.list()) {
99
+ for (const activation of trail.activationSources) {
100
+ if (activation.source.kind !== 'signal') {
101
+ continue;
102
+ }
103
+ const consumers =
104
+ consumersBySignal.get(activation.source.id) ?? new Set<string>();
105
+ consumers.add(trail.id);
106
+ consumersBySignal.set(activation.source.id, consumers);
107
+ }
108
+ }
109
+
110
+ return new Map(
111
+ [...consumersBySignal.entries()].map(([signalId, consumers]) => [
112
+ signalId,
113
+ sortedUnique(consumers),
114
+ ])
115
+ );
116
+ };
117
+
118
+ const collectRelations = (topo: Topo): ReadonlyMap<string, SignalRelations> => {
119
+ const producerTrails = collectProducerTrails(topo);
120
+ const producerResources = collectProducerResources(topo);
121
+ const consumers = collectConsumers(topo);
122
+
123
+ return new Map(
124
+ collectSignalIds(topo).map((signalId) => [
125
+ signalId,
126
+ {
127
+ consumers: consumers.get(signalId) ?? [],
128
+ producerResources: producerResources.get(signalId) ?? [],
129
+ producerTrails: producerTrails.get(signalId) ?? [],
130
+ },
131
+ ])
132
+ );
133
+ };
134
+
135
+ const quoteList = (values: readonly string[]): string =>
136
+ values.map((value) => `"${value}"`).join(', ');
137
+
138
+ const formatProducerClause = ({
139
+ producerResources,
140
+ producerTrails,
141
+ }: SignalRelations): string => {
142
+ const clauses: string[] = [];
143
+ if (producerTrails.length > 0) {
144
+ clauses.push(
145
+ `producer trail${producerTrails.length === 1 ? '' : 's'} ${quoteList(producerTrails)}`
146
+ );
147
+ }
148
+ if (producerResources.length > 0) {
149
+ clauses.push(
150
+ `producer resource${producerResources.length === 1 ? '' : 's'} ${quoteList(producerResources)}`
151
+ );
152
+ }
153
+ return clauses.join(' and ');
154
+ };
155
+
156
+ const buildDeadSignalDiagnostic = (signalId: string): WardenDiagnostic => ({
157
+ filePath: TOPO_FILE,
158
+ line: 1,
159
+ 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.`,
160
+ rule: RULE_NAME,
161
+ severity: 'warn',
162
+ });
163
+
164
+ const buildProducedWithoutConsumerDiagnostic = (
165
+ signalId: string,
166
+ relations: SignalRelations
167
+ ): WardenDiagnostic => ({
168
+ filePath: TOPO_FILE,
169
+ line: 1,
170
+ 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.`,
171
+ rule: RULE_NAME,
172
+ severity: 'warn',
173
+ });
174
+
175
+ const hasProducer = ({
176
+ producerResources,
177
+ producerTrails,
178
+ }: SignalRelations): boolean =>
179
+ producerResources.length > 0 || producerTrails.length > 0;
180
+
181
+ const hasConsumer = ({ consumers }: SignalRelations): boolean =>
182
+ consumers.length > 0;
183
+
184
+ const buildDiagnostics = (
185
+ relationsBySignal: ReadonlyMap<string, SignalRelations>,
186
+ storeDerivedSignalIds: ReadonlySet<string>
187
+ ): readonly WardenDiagnostic[] => {
188
+ const diagnostics: WardenDiagnostic[] = [];
189
+
190
+ for (const [signalId, relations] of relationsBySignal) {
191
+ if (!hasProducer(relations) && !hasConsumer(relations)) {
192
+ diagnostics.push(buildDeadSignalDiagnostic(signalId));
193
+ continue;
194
+ }
195
+
196
+ if (
197
+ hasProducer(relations) &&
198
+ !hasConsumer(relations) &&
199
+ !storeDerivedSignalIds.has(signalId)
200
+ ) {
201
+ diagnostics.push(
202
+ buildProducedWithoutConsumerDiagnostic(signalId, relations)
203
+ );
204
+ }
205
+ }
206
+
207
+ return diagnostics;
208
+ };
209
+
210
+ export const signalGraphCoaching: TopoAwareWardenRule = {
211
+ checkTopo: (topo) =>
212
+ buildDiagnostics(
213
+ collectRelations(topo),
214
+ collectStoreDerivedSignalIds(topo)
215
+ ),
216
+ description:
217
+ 'Warn when typed signal contracts are declared or produced without reactive consumers.',
218
+ name: RULE_NAME,
219
+ severity: 'warn',
220
+ };
@@ -0,0 +1,165 @@
1
+ /** Warden-private composition helpers. */
2
+
3
+ import { intentValues } from '@ontrails/core';
4
+ import type { Intent } from '@ontrails/core';
5
+
6
+ import {
7
+ buildFrameworkNamespaceContext,
8
+ deriveConstString,
9
+ extractBindingName,
10
+ extractTrailDefinition,
11
+ findConfigProperty,
12
+ findTrailDefinitions,
13
+ getStringValue,
14
+ identifierName,
15
+ isStringLiteral,
16
+ walk,
17
+ } from '@ontrails/source';
18
+ import type { AstNode } from '@ontrails/source';
19
+
20
+ /** Collect `const foo = trail('id', ...)` bindings from a parsed file. */
21
+ export const collectNamedTrailIds = (
22
+ ast: AstNode
23
+ ): ReadonlyMap<string, string> => {
24
+ const ids = new Map<string, string>();
25
+ const context = buildFrameworkNamespaceContext(ast);
26
+
27
+ walk(ast, (node) => {
28
+ if (node.type !== 'VariableDeclarator') {
29
+ return;
30
+ }
31
+
32
+ const { id, init } = node as unknown as {
33
+ readonly id?: AstNode;
34
+ readonly init?: AstNode;
35
+ };
36
+ if (!init) {
37
+ return;
38
+ }
39
+
40
+ const def = extractTrailDefinition(init, context);
41
+ const name = extractBindingName(id);
42
+ if (def?.kind === 'trail' && name) {
43
+ ids.set(name, def.id);
44
+ }
45
+ });
46
+
47
+ return ids;
48
+ };
49
+
50
+ /** Extract the raw `composes: [...]` array elements from a trail config. */
51
+ export const getComposeElements = (config: AstNode): readonly AstNode[] => {
52
+ const composesProp = findConfigProperty(config, 'composes');
53
+ if (!composesProp) {
54
+ return [];
55
+ }
56
+
57
+ const arrayNode = composesProp.value;
58
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
59
+ return [];
60
+ }
61
+
62
+ const elements = (arrayNode as AstNode)['elements'] as
63
+ | readonly AstNode[]
64
+ | undefined;
65
+ return elements ?? [];
66
+ };
67
+
68
+ /**
69
+ * Resolve a single `composes: [...]` element to its target trail ID.
70
+ *
71
+ * Handles string literals, identifier references (via `namedTrailIds` map or
72
+ * `const NAME = '...'` resolution), and inline `trail(...)` call expressions.
73
+ */
74
+ export const deriveComposeElementId = (
75
+ element: AstNode,
76
+ sourceCode: string,
77
+ namedTrailIds: ReadonlyMap<string, string>
78
+ ): string | null => {
79
+ if (isStringLiteral(element)) {
80
+ return getStringValue(element);
81
+ }
82
+
83
+ if (element.type === 'Identifier') {
84
+ const name = identifierName(element);
85
+ return name
86
+ ? (namedTrailIds.get(name) ?? deriveConstString(name, sourceCode))
87
+ : null;
88
+ }
89
+
90
+ const inlineDef = extractTrailDefinition(element);
91
+ return inlineDef?.kind === 'trail' ? inlineDef.id : null;
92
+ };
93
+
94
+ /**
95
+ * Collect all trail IDs referenced by a single trail definition's
96
+ * `composes: [...]` array, deduplicated.
97
+ */
98
+ export const extractDefinitionComposeTargetIds = (
99
+ config: AstNode,
100
+ sourceCode: string,
101
+ namedTrailIds: ReadonlyMap<string, string>
102
+ ): readonly string[] => [
103
+ ...new Set(
104
+ getComposeElements(config).flatMap((element) => {
105
+ const id = deriveComposeElementId(element, sourceCode, namedTrailIds);
106
+ return id ? [id] : [];
107
+ })
108
+ ),
109
+ ];
110
+
111
+ /** Collect all trail IDs referenced by declared `composes: [...]` arrays. */
112
+ export const collectComposeTargetTrailIds = (
113
+ ast: AstNode,
114
+ sourceCode: string
115
+ ): ReadonlySet<string> => {
116
+ const ids = new Set<string>();
117
+ const namedTrailIds = collectNamedTrailIds(ast);
118
+
119
+ for (const def of findTrailDefinitions(ast)) {
120
+ if (def.kind !== 'trail') {
121
+ continue;
122
+ }
123
+
124
+ for (const id of extractDefinitionComposeTargetIds(
125
+ def.config,
126
+ sourceCode,
127
+ namedTrailIds
128
+ )) {
129
+ ids.add(id);
130
+ }
131
+ }
132
+
133
+ return ids;
134
+ };
135
+
136
+ const INTENT_VALUE_SET = new Set<string>(intentValues);
137
+ const DEFAULT_INTENT: Intent = 'write';
138
+
139
+ const normalizeTrailIntent = (value: string): Intent =>
140
+ INTENT_VALUE_SET.has(value) ? (value as Intent) : DEFAULT_INTENT;
141
+
142
+ const extractTrailIntent = (config: AstNode): Intent => {
143
+ const intentProp = findConfigProperty(config, 'intent');
144
+ if (!intentProp || !isStringLiteral(intentProp.value as AstNode)) {
145
+ return DEFAULT_INTENT;
146
+ }
147
+
148
+ const value = getStringValue(intentProp.value as AstNode);
149
+ return value ? normalizeTrailIntent(value) : DEFAULT_INTENT;
150
+ };
151
+
152
+ /** Collect the normalized intent for every trail definition in a parsed file. */
153
+ export const collectTrailIntentsById = (
154
+ ast: AstNode
155
+ ): ReadonlyMap<string, Intent> => {
156
+ const intents = new Map<string, Intent>();
157
+
158
+ for (const def of findTrailDefinitions(ast)) {
159
+ if (def.kind === 'trail') {
160
+ intents.set(def.id, extractTrailIntent(def.config));
161
+ }
162
+ }
163
+
164
+ return intents;
165
+ };
@@ -0,0 +1,164 @@
1
+ /** Warden-private draft policy helpers. */
2
+
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import { DRAFT_ID_PREFIX } from '@ontrails/core';
8
+
9
+ import {
10
+ getStringValue,
11
+ identifierName,
12
+ isStringLiteral,
13
+ walk,
14
+ } from '@ontrails/source';
15
+ import type { AstNode } from '@ontrails/source';
16
+
17
+ /**
18
+ * Names of framework constants whose value is a draft-marker prefix literal.
19
+ *
20
+ * String literals that initialize a `const` declaration with one of these
21
+ * names are treated as the framework's own draft-marker declarations, not as
22
+ * draft-id usage. This list is intentionally small and explicit — adding a
23
+ * new framework draft-prefix constant requires updating this set.
24
+ */
25
+ export const FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES: ReadonlySet<string> =
26
+ new Set(['DRAFT_ID_PREFIX', 'DRAFT_FILE_PREFIX']);
27
+
28
+ /**
29
+ * Exact string literal value allowed for framework draft-prefix constant
30
+ * declarations. Tightens the exemption so a future framework file cannot
31
+ * redeclare `DRAFT_ID_PREFIX = '_draft.something-else'` and accidentally
32
+ * suppress its own draft-id diagnostic.
33
+ */
34
+ const FRAMEWORK_DRAFT_PREFIX_LITERAL = DRAFT_ID_PREFIX;
35
+
36
+ interface PackageJsonWithName {
37
+ readonly name: string;
38
+ }
39
+
40
+ const FRAMEWORK_DRAFT_PREFIX_PACKAGES: ReadonlySet<string> = new Set([
41
+ '@ontrails/core',
42
+ '@ontrails/warden',
43
+ ]);
44
+
45
+ const isPackageJsonWithName = (value: unknown): value is PackageJsonWithName =>
46
+ typeof value === 'object' &&
47
+ value !== null &&
48
+ typeof (value as { name?: unknown }).name === 'string';
49
+
50
+ const readPackageJsonName = (packageJsonPath: string): string | null => {
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
53
+ return isPackageJsonWithName(parsed) ? parsed.name : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ };
58
+
59
+ const frameworkDraftPackageRoot = (filePath: string): string | null => {
60
+ const resolvedPath = resolve(filePath);
61
+ if (basename(resolvedPath) !== 'draft.ts') {
62
+ return null;
63
+ }
64
+
65
+ const sourceDir = dirname(resolvedPath);
66
+ if (basename(sourceDir) !== 'src') {
67
+ return null;
68
+ }
69
+
70
+ const packageRoot = dirname(sourceDir);
71
+ if (!existsSync(join(packageRoot, 'package.json'))) {
72
+ return null;
73
+ }
74
+
75
+ return packageRoot;
76
+ };
77
+
78
+ /** Fallback exemption when framework files are consumed from a different install path. */
79
+ const isFrameworkDraftPrefixSourceFile = (filePath: string): boolean => {
80
+ const root = frameworkDraftPackageRoot(filePath);
81
+ if (!root) {
82
+ return false;
83
+ }
84
+ const packageName = readPackageJsonName(join(root, 'package.json'));
85
+ return (
86
+ packageName !== null && FRAMEWORK_DRAFT_PREFIX_PACKAGES.has(packageName)
87
+ );
88
+ };
89
+
90
+ /**
91
+ * Absolute paths of the two framework files allowed to declare the
92
+ * draft-prefix constants. Anchored against the rule module's own URL so the
93
+ * exemption is scoped to this package's real on-disk location — a consumer
94
+ * repository that happens to declare `const DRAFT_ID_PREFIX = '_draft.leak'`
95
+ * anywhere else cannot hide a genuine leak by matching the identifier name.
96
+ *
97
+ * The two framework files are:
98
+ * - `packages/core/src/draft.ts` (defines `DRAFT_ID_PREFIX`)
99
+ * - `packages/warden/src/draft.ts` (defines `DRAFT_FILE_PREFIX`)
100
+ */
101
+ const FRAMEWORK_DRAFT_CONSTANT_FILES: ReadonlySet<string> = new Set([
102
+ resolve(
103
+ fileURLToPath(new URL('../../../../core/src/draft.ts', import.meta.url))
104
+ ),
105
+ resolve(fileURLToPath(new URL('../../draft.ts', import.meta.url))),
106
+ ]);
107
+
108
+ /**
109
+ * Collect the source offsets of string literals that initialize a framework
110
+ * draft-prefix constant declaration (e.g. `export const DRAFT_ID_PREFIX =
111
+ * '_draft.'`). Used by draft-awareness rules to skip their own marker
112
+ * constants.
113
+ *
114
+ * Exemption is gated on all three of:
115
+ * 1. The file is one of the two known framework draft files, or its package
116
+ * root `package.json` name is `@ontrails/core` or `@ontrails/warden`.
117
+ * 2. The declaration name is `DRAFT_ID_PREFIX` or `DRAFT_FILE_PREFIX`.
118
+ * 3. The string literal value is exactly `'_draft.'`.
119
+ *
120
+ * A consumer file that reuses one of these identifier names cannot hide a
121
+ * `_draft.*` leak — the path gate rejects it outright.
122
+ */
123
+ export const collectFrameworkDraftPrefixConstantOffsets = (
124
+ ast: AstNode,
125
+ filePath: string
126
+ ): ReadonlySet<number> => {
127
+ const offsets = new Set<number>();
128
+
129
+ const resolvedPath = resolve(filePath);
130
+ if (
131
+ !FRAMEWORK_DRAFT_CONSTANT_FILES.has(resolvedPath) &&
132
+ !isFrameworkDraftPrefixSourceFile(resolvedPath)
133
+ ) {
134
+ return offsets;
135
+ }
136
+
137
+ walk(ast, (node) => {
138
+ if (node.type !== 'VariableDeclarator') {
139
+ return;
140
+ }
141
+
142
+ const { id, init } = node as unknown as {
143
+ readonly id?: AstNode;
144
+ readonly init?: AstNode;
145
+ };
146
+ const name = identifierName(id);
147
+ if (
148
+ !name ||
149
+ !FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES.has(name) ||
150
+ !init ||
151
+ !isStringLiteral(init)
152
+ ) {
153
+ return;
154
+ }
155
+
156
+ if (getStringValue(init) !== FRAMEWORK_DRAFT_PREFIX_LITERAL) {
157
+ return;
158
+ }
159
+
160
+ offsets.add(init.start);
161
+ });
162
+
163
+ return offsets;
164
+ };