@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/config.ts ADDED
@@ -0,0 +1,193 @@
1
+ import { z } from 'zod';
2
+
3
+ import type { WardenDiagnostic } from './rules/types.js';
4
+
5
+ export const wardenDepthValues = ['source', 'project', 'topo', 'all'] as const;
6
+ export const wardenFailOnValues = ['error', 'warning'] as const;
7
+ export const wardenFormatValues = ['summary', 'github', 'json'] as const;
8
+ export const wardenLockValues = ['auto', 'cached', 'refresh', 'skip'] as const;
9
+ export const wardenDraftsValues = ['include', 'exclude', 'only'] as const;
10
+
11
+ const appNameSchema = z.string().min(1);
12
+
13
+ const wardenScopeSchema = z
14
+ .object({
15
+ exclude: z.array(z.string().min(1)).default([]),
16
+ })
17
+ .strict()
18
+ .default({ exclude: [] });
19
+
20
+ const wardenConfigObjectSchema = z
21
+ .object({
22
+ apps: z.array(appNameSchema).min(1).optional(),
23
+ depth: z.enum(wardenDepthValues).default('all'),
24
+ drafts: z.enum(wardenDraftsValues).default('include'),
25
+ failOn: z.enum(wardenFailOnValues).default('error'),
26
+ format: z.enum(wardenFormatValues).default('summary'),
27
+ lock: z.enum(wardenLockValues).default('auto'),
28
+ scope: wardenScopeSchema,
29
+ })
30
+ .strict();
31
+
32
+ export const wardenConfigSchema = wardenConfigObjectSchema
33
+ .optional()
34
+ .transform((value) => wardenConfigObjectSchema.parse(value ?? {}));
35
+
36
+ export type WardenConfig = z.output<typeof wardenConfigSchema>;
37
+ export type WardenConfigInput = z.input<typeof wardenConfigSchema>;
38
+ export type WardenDepth = (typeof wardenDepthValues)[number];
39
+ export type WardenDraftsMode = (typeof wardenDraftsValues)[number];
40
+ export type WardenFailOn = (typeof wardenFailOnValues)[number];
41
+ export type WardenFormat = (typeof wardenFormatValues)[number];
42
+ export type WardenScope = z.output<typeof wardenScopeSchema>;
43
+ export type WardenLockMode = (typeof wardenLockValues)[number];
44
+
45
+ export interface WardenConfigLayer extends Partial<WardenConfig> {
46
+ readonly noLockMutation?: boolean | undefined;
47
+ }
48
+
49
+ export interface EffectiveWardenConfig extends WardenConfig {
50
+ readonly noLockMutation: boolean;
51
+ }
52
+
53
+ export interface ResolveWardenConfigOptions {
54
+ readonly cli?: WardenConfigLayer | undefined;
55
+ readonly config?: WardenConfigInput | undefined;
56
+ readonly defaults?: Partial<WardenConfig> | undefined;
57
+ readonly env?: Record<string, string | undefined> | undefined;
58
+ }
59
+
60
+ export interface WardenConfigResolution {
61
+ readonly diagnostics: readonly WardenDiagnostic[];
62
+ readonly effectiveConfig: EffectiveWardenConfig;
63
+ }
64
+
65
+ const baseWardenConfig = (): WardenConfig => {
66
+ const omittedSection: unknown = undefined;
67
+ return wardenConfigSchema.parse(omittedSection);
68
+ };
69
+
70
+ const cleanUndefinedValues = <T extends Record<string, unknown>>(
71
+ value: T
72
+ ): Partial<T> =>
73
+ Object.fromEntries(
74
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
75
+ ) as Partial<T>;
76
+
77
+ const splitApps = (value: string): readonly string[] =>
78
+ value
79
+ .split(',')
80
+ .map((entry) => entry.trim())
81
+ .filter((entry) => entry.length > 0);
82
+
83
+ const readEnvLayer = (
84
+ env: Record<string, string | undefined>
85
+ ): Partial<WardenConfig> =>
86
+ cleanUndefinedValues({
87
+ apps: env['TRAILS_APPS'] ? splitApps(env['TRAILS_APPS']) : undefined,
88
+ depth: env['TRAILS_DEPTH'],
89
+ drafts: env['TRAILS_DRAFTS'],
90
+ failOn: env['TRAILS_FAIL_ON'],
91
+ format: env['TRAILS_FORMAT'],
92
+ lock: env['TRAILS_LOCK'],
93
+ }) as Partial<WardenConfig>;
94
+
95
+ const configDiagnostic = (message: string): WardenDiagnostic => ({
96
+ filePath: '<warden-config>',
97
+ line: 1,
98
+ message,
99
+ rule: 'warden-config',
100
+ severity: 'error',
101
+ });
102
+
103
+ const formatIssues = (error: z.ZodError): string =>
104
+ error.issues
105
+ .map((issue) => {
106
+ const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
107
+ return `${path}: ${issue.message}`;
108
+ })
109
+ .join('; ');
110
+
111
+ const parseConfigLayer = (
112
+ label: string,
113
+ value: WardenConfigInput | undefined
114
+ ): {
115
+ readonly data: Partial<WardenConfig>;
116
+ readonly diagnostics: readonly WardenDiagnostic[];
117
+ } => {
118
+ if (value === undefined) {
119
+ return { data: {}, diagnostics: [] };
120
+ }
121
+
122
+ const parsed = wardenConfigSchema.safeParse(value);
123
+ if (parsed.success) {
124
+ if (typeof value !== 'object' || value === null) {
125
+ return { data: parsed.data, diagnostics: [] };
126
+ }
127
+
128
+ return {
129
+ data: Object.fromEntries(
130
+ Object.keys(value).map((key) => [
131
+ key,
132
+ parsed.data[key as keyof WardenConfig],
133
+ ])
134
+ ) as Partial<WardenConfig>,
135
+ diagnostics: [],
136
+ };
137
+ }
138
+
139
+ return {
140
+ data: {},
141
+ diagnostics: [
142
+ configDiagnostic(
143
+ `Invalid ${label} Warden config: ${formatIssues(parsed.error)}`
144
+ ),
145
+ ],
146
+ };
147
+ };
148
+
149
+ export const resolveWardenConfig = ({
150
+ cli,
151
+ config,
152
+ defaults,
153
+ env = {},
154
+ }: ResolveWardenConfigOptions = {}): WardenConfigResolution => {
155
+ const { noLockMutation = false, ...cliConfig } = cli ?? {};
156
+ const defaultLayer = wardenConfigSchema.parse({
157
+ ...baseWardenConfig(),
158
+ ...defaults,
159
+ });
160
+ const configLayer = parseConfigLayer('file', config);
161
+ const envLayer = parseConfigLayer('environment', readEnvLayer(env));
162
+ const merged = {
163
+ ...defaultLayer,
164
+ ...configLayer.data,
165
+ ...envLayer.data,
166
+ ...cleanUndefinedValues(cliConfig),
167
+ };
168
+ const parsed = wardenConfigSchema.safeParse(merged);
169
+ const diagnostics = [...configLayer.diagnostics, ...envLayer.diagnostics];
170
+
171
+ if (!parsed.success) {
172
+ return {
173
+ diagnostics: [
174
+ ...diagnostics,
175
+ configDiagnostic(
176
+ `Invalid effective Warden config: ${formatIssues(parsed.error)}`
177
+ ),
178
+ ],
179
+ effectiveConfig: {
180
+ ...defaultLayer,
181
+ noLockMutation,
182
+ },
183
+ };
184
+ }
185
+
186
+ return {
187
+ diagnostics,
188
+ effectiveConfig: {
189
+ ...parsed.data,
190
+ noLockMutation,
191
+ },
192
+ };
193
+ };
package/src/draft.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { basename } from 'node:path';
2
+
3
+ export const DRAFT_FILE_PREFIX = '_draft.';
4
+ export const DRAFT_FILE_SEGMENT = '.draft.';
5
+
6
+ const DRAFT_TRAILING_SEGMENT = /\.draft(?=\.[^.]+$)/;
7
+
8
+ export const isDraftMarkedFile = (filePath: string): boolean => {
9
+ const fileName = basename(filePath);
10
+ return (
11
+ fileName.startsWith(DRAFT_FILE_PREFIX) ||
12
+ fileName.includes(DRAFT_FILE_SEGMENT)
13
+ );
14
+ };
15
+
16
+ export const stripDraftFileMarkers = (fileName: string): string => {
17
+ if (fileName.startsWith(DRAFT_FILE_PREFIX)) {
18
+ return fileName.slice(DRAFT_FILE_PREFIX.length);
19
+ }
20
+
21
+ return fileName.replace(DRAFT_TRAILING_SEGMENT, '');
22
+ };
package/src/drift.ts CHANGED
@@ -1,50 +1,260 @@
1
1
  /**
2
- * Surface lock drift detection.
2
+ * Trails lock drift detection.
3
3
  *
4
- * Compares the committed `surface.lock` hash against a freshly generated
5
- * surface map hash to detect when the trail topology has changed without
6
- * updating the lock file.
4
+ * Compares the committed `trails.lock` TopoGraph hash against a freshly
5
+ * generated TopoGraph hash to detect when the trail topology changed without
6
+ * updating the committed resolved truth.
7
7
  */
8
8
 
9
+ import { existsSync, statSync } from 'node:fs';
10
+
9
11
  import type { Topo } from '@ontrails/core';
10
12
  import {
11
- generateSurfaceMap,
12
- hashSurfaceMap,
13
- readSurfaceLock,
14
- } from '@ontrails/schema';
13
+ deriveTrailsDir,
14
+ NotFoundError,
15
+ ValidationError,
16
+ } from '@ontrails/core';
17
+ import {
18
+ collectTopoGraphOverlays,
19
+ createTopoStore,
20
+ deriveTopoGraph,
21
+ deriveTopoGraphHash,
22
+ isTopoArtifactRegenerationError,
23
+ LOCK_MANIFEST_SCHEMA_VERSION,
24
+ readLockManifest,
25
+ readTopoGraph,
26
+ readTrailsLock,
27
+ } from '@ontrails/topography';
28
+ import type {
29
+ DeriveTopoGraphOptions,
30
+ LockManifest,
31
+ TopoGraphOverlays,
32
+ } from '@ontrails/topography';
15
33
 
16
34
  /**
17
- * Result of a drift check comparing committed surface.lock against the current state.
35
+ * Derive options `checkDrift` accepts so the fresh comparison graph carries
36
+ * the same app-module overlays the committed lock embeds.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import type { CheckDriftOptions } from '@ontrails/warden';
41
+ *
42
+ * const options: CheckDriftOptions = { overlays: lease.overlays };
43
+ * ```
44
+ */
45
+ export type CheckDriftOptions = Pick<DeriveTopoGraphOptions, 'overlays'>;
46
+
47
+ /**
48
+ * Result of a drift check comparing committed trails.lock against the current state.
18
49
  */
19
50
  export interface DriftResult {
51
+ /** Why drift could not be computed for the established graph, when blocked. */
52
+ readonly blockedReason?: string | undefined;
20
53
  /** Whether the committed lock is out of date */
21
54
  readonly stale: boolean;
22
- /** Hash from the committed surface.lock file, or null if not found */
55
+ /** Hash from the committed trails.lock file, or null if not found */
23
56
  readonly committedHash: string | null;
24
57
  /** Hash computed from the current trail topology */
25
58
  readonly currentHash: string;
59
+ /**
60
+ * Overlay namespaces whose committed facts differ from the freshly derived
61
+ * facts, sorted lexicographically. Present only when the lock is stale and
62
+ * the caller supplied a topo plus derive options.
63
+ */
64
+ readonly driftedOverlayNamespaces?: readonly string[] | undefined;
65
+ }
66
+
67
+ interface BlockedLockRead {
68
+ readonly drift: DriftResult;
69
+ readonly kind: 'blocked-lock-read';
26
70
  }
27
71
 
72
+ const blockedDrift = (reason: string): DriftResult => ({
73
+ blockedReason: reason,
74
+ committedHash: null,
75
+ currentHash: 'blocked',
76
+ stale: true,
77
+ });
78
+
79
+ const blockedLockRead = (reason: string): BlockedLockRead => ({
80
+ drift: blockedDrift(reason),
81
+ kind: 'blocked-lock-read',
82
+ });
83
+
84
+ const readCommittedLockManifest = async (
85
+ rootDir: string
86
+ ): Promise<BlockedLockRead | LockManifest | null> => {
87
+ try {
88
+ if (!(existsSync(rootDir) && statSync(rootDir).isDirectory())) {
89
+ return null;
90
+ }
91
+ const rootLock = await readTrailsLock({ dir: rootDir });
92
+ if (rootLock !== null) {
93
+ return {
94
+ artifacts: [
95
+ {
96
+ path: 'topo.lock',
97
+ role: 'topo',
98
+ sha256: rootLock.topoGraphHash,
99
+ },
100
+ ],
101
+ scope: rootLock.scope,
102
+ summary: rootLock.summary,
103
+ version: LOCK_MANIFEST_SCHEMA_VERSION,
104
+ };
105
+ }
106
+ return await readLockManifest({ dir: deriveTrailsDir({ rootDir }) });
107
+ } catch (error) {
108
+ if (isTopoArtifactRegenerationError(error)) {
109
+ return blockedLockRead(error.message);
110
+ }
111
+ throw error;
112
+ }
113
+ };
114
+
115
+ const isBlockedLockRead = (
116
+ result: BlockedLockRead | LockManifest | null
117
+ ): result is BlockedLockRead =>
118
+ result !== null && 'kind' in result && result.kind === 'blocked-lock-read';
119
+
120
+ /**
121
+ * Read the committed lock's embedded graph overlays, tolerating both the v4
122
+ * root `trails.lock` envelope and the legacy `.trails/` artifact layout.
123
+ */
124
+ const readCommittedGraphOverlays = async (
125
+ rootDir: string
126
+ ): Promise<TopoGraphOverlays | undefined> => {
127
+ const committedGraph =
128
+ (await readTopoGraph({ dir: rootDir })) ??
129
+ (await readTopoGraph({ dir: deriveTrailsDir({ rootDir }) }));
130
+ return committedGraph?.overlays;
131
+ };
132
+
28
133
  /**
29
- * Check whether the committed surface.lock is stale compared to the current topology.
134
+ * Name the overlay namespaces whose committed facts drifted from the freshly
135
+ * derived facts. Compares canonical JSON per namespace across the union of
136
+ * committed and current namespaces; returns a sorted list.
137
+ */
138
+ const collectDriftedOverlayNamespaces = async (
139
+ rootDir: string,
140
+ topo: Topo,
141
+ options: CheckDriftOptions
142
+ ): Promise<readonly string[]> => {
143
+ let committed: TopoGraphOverlays | undefined;
144
+ try {
145
+ committed = await readCommittedGraphOverlays(rootDir);
146
+ } catch {
147
+ return [];
148
+ }
149
+ const current = collectTopoGraphOverlays(topo, options.overlays);
150
+ const namespaces = new Set([
151
+ ...Object.keys(committed ?? {}),
152
+ ...Object.keys(current ?? {}),
153
+ ]);
154
+ return [...namespaces]
155
+ .filter(
156
+ (namespace) =>
157
+ JSON.stringify(committed?.[namespace]) !==
158
+ JSON.stringify(current?.[namespace])
159
+ )
160
+ .toSorted();
161
+ };
162
+
163
+ /**
164
+ * Format a stale drift result into one human-readable sentence.
165
+ *
166
+ * Names the drifted overlay namespaces when the drift check identified them,
167
+ * and always points at `trails compile` as the remediation.
168
+ *
169
+ * @example
170
+ * ```ts
171
+ * import { staleDriftMessage } from './drift.js';
172
+ *
173
+ * staleDriftMessage({
174
+ * committedHash: 'aaa',
175
+ * currentHash: 'bbb',
176
+ * driftedOverlayNamespaces: ['surfaces'],
177
+ * stale: true,
178
+ * });
179
+ * // => 'trails.lock is stale — drifted overlay namespaces: surfaces (regenerate with `trails compile`)'
180
+ * ```
181
+ */
182
+ export const staleDriftMessage = (drift: DriftResult): string => {
183
+ const namespaces = drift.driftedOverlayNamespaces;
184
+ const detail =
185
+ namespaces !== undefined && namespaces.length > 0
186
+ ? ` — drifted overlay namespaces: ${namespaces.join(', ')}`
187
+ : '';
188
+ return `trails.lock is stale${detail} (regenerate with \`trails compile\`)`;
189
+ };
190
+
191
+ /**
192
+ * Check whether the committed trails.lock is stale compared to the current topology.
30
193
  *
31
194
  * When no topo is provided, returns a clean result (no drift detectable without runtime info).
195
+ * When a topo is provided, `options.overlays` carries the app-module overlay
196
+ * registrations so the fresh comparison graph embeds the same namespaced
197
+ * facts the compile path writes into the committed lock.
32
198
  */
33
199
  export const checkDrift = async (
34
200
  rootDir: string,
35
- topo?: Topo | undefined
201
+ topo?: Topo | undefined,
202
+ options?: CheckDriftOptions | undefined
36
203
  ): Promise<DriftResult> => {
37
- if (!topo) {
38
- return { committedHash: null, currentHash: 'unknown', stale: false };
39
- }
204
+ try {
205
+ const lockManifest = await readCommittedLockManifest(rootDir);
206
+ if (isBlockedLockRead(lockManifest)) {
207
+ return lockManifest.drift;
208
+ }
209
+ const topoArtifact =
210
+ lockManifest?.artifacts.find(
211
+ (artifact) => artifact.role === 'topo' && artifact.path === 'topo.lock'
212
+ ) ?? null;
213
+ if (lockManifest !== null && topoArtifact === null) {
214
+ return blockedDrift(
215
+ 'trails.lock does not contain a topo.lock artifact. Regenerate with `trails compile`.'
216
+ );
217
+ }
218
+ const readStoredHash = (): string | undefined => {
219
+ try {
220
+ return createTopoStore({ rootDir }).exports.get()?.topoGraphHash;
221
+ } catch (error) {
222
+ if (error instanceof NotFoundError) {
223
+ return;
224
+ }
225
+ throw error;
226
+ }
227
+ };
228
+ const currentHash =
229
+ topo === undefined
230
+ ? (readStoredHash() ?? 'unknown')
231
+ : deriveTopoGraphHash(deriveTopoGraph(topo, options));
232
+ const stale =
233
+ topoArtifact !== null &&
234
+ currentHash !== 'unknown' &&
235
+ topoArtifact.sha256 !== currentHash;
236
+ const driftedOverlayNamespaces =
237
+ stale && topo !== undefined && options !== undefined
238
+ ? await collectDriftedOverlayNamespaces(rootDir, topo, options)
239
+ : undefined;
40
240
 
41
- const surfaceMap = generateSurfaceMap(topo);
42
- const currentHash = hashSurfaceMap(surfaceMap);
43
- const committedHash = await readSurfaceLock({ dir: rootDir });
241
+ return {
242
+ committedHash: topoArtifact?.sha256 ?? null,
243
+ currentHash,
244
+ ...(driftedOverlayNamespaces === undefined ||
245
+ driftedOverlayNamespaces.length === 0
246
+ ? {}
247
+ : { driftedOverlayNamespaces }),
248
+ stale,
249
+ };
250
+ } catch (error) {
251
+ if (
252
+ !(error instanceof ValidationError) &&
253
+ !isTopoArtifactRegenerationError(error)
254
+ ) {
255
+ throw error;
256
+ }
44
257
 
45
- return {
46
- committedHash,
47
- currentHash,
48
- stale: committedHash !== null && committedHash !== currentHash,
49
- };
258
+ return blockedDrift(error.message);
259
+ }
50
260
  };
package/src/fix.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Safe-fix execution for `warden --fix` (TRL-833).
3
+ *
4
+ * Consumes the structured {@link WardenFix} metadata a rule attaches to its
5
+ * diagnostics (TRL-831) and applies only the edits marked `safe`. Findings
6
+ * whose fix is `review`-required, or that carry no edits, are never applied —
7
+ * they stay reported so a human (or a downstream regrade) resolves them.
8
+ *
9
+ * The applicator is pure: it takes a file's source plus that file's
10
+ * diagnostics and returns the patched source plus which diagnostics were
11
+ * applied or skipped. The CLI layer owns reading and writing files.
12
+ */
13
+
14
+ import type { WardenDiagnostic, WardenFixEdit } from './rules/types.js';
15
+
16
+ /** A safe edit resolved from a diagnostic, ready to apply to a source string. */
17
+ interface ResolvedEdit {
18
+ readonly start: number;
19
+ readonly end: number;
20
+ readonly replacement: string;
21
+ }
22
+
23
+ /**
24
+ * Apply a set of edits to a source string, last-to-first.
25
+ *
26
+ * Edits are applied in descending start order so earlier offsets stay valid as
27
+ * later spans are spliced. Overlapping edits are a programming error in the
28
+ * rule that produced them; this throws rather than silently corrupt source.
29
+ */
30
+ const applyEdits = (source: string, edits: readonly ResolvedEdit[]): string => {
31
+ for (const edit of edits) {
32
+ if (!Number.isSafeInteger(edit.start) || !Number.isSafeInteger(edit.end)) {
33
+ throw new RangeError(
34
+ `Fix edit [${String(edit.start)}, ${String(edit.end)}) must use safe integer offsets.`
35
+ );
36
+ }
37
+ }
38
+
39
+ const ordered = [...edits].toSorted(
40
+ (left, right) => right.start - left.start
41
+ );
42
+ let result = source;
43
+ let lastStart = Number.POSITIVE_INFINITY;
44
+ for (const edit of ordered) {
45
+ if (edit.start < 0 || edit.end > source.length || edit.start > edit.end) {
46
+ throw new RangeError(
47
+ `Fix edit [${edit.start}, ${edit.end}) is out of bounds for source of length ${source.length}.`
48
+ );
49
+ }
50
+ if (edit.end > lastStart) {
51
+ throw new RangeError(
52
+ `Fix edit [${edit.start}, ${edit.end}) overlaps a later edit starting at ${lastStart}.`
53
+ );
54
+ }
55
+ result =
56
+ result.slice(0, edit.start) + edit.replacement + result.slice(edit.end);
57
+ lastStart = edit.start;
58
+ }
59
+ return result;
60
+ };
61
+
62
+ /** Whether a diagnostic carries an applicable safe fix with concrete edits. */
63
+ export const hasSafeFixEdits = (
64
+ diagnostic: WardenDiagnostic
65
+ ): diagnostic is WardenDiagnostic & {
66
+ readonly fix: { readonly edits: readonly WardenFixEdit[] };
67
+ } =>
68
+ diagnostic.fix?.safety === 'safe' &&
69
+ diagnostic.fix.edits !== undefined &&
70
+ diagnostic.fix.edits.length > 0;
71
+
72
+ /** Result of applying safe fixes to a single file's source. */
73
+ export interface WardenFileFixResult {
74
+ /** Source after applying every safe edit; unchanged when none applied. */
75
+ readonly patched: string;
76
+ /** Whether any edit was applied (i.e. `patched` differs from input). */
77
+ readonly changed: boolean;
78
+ /** Diagnostics whose safe fix was applied. */
79
+ readonly applied: readonly WardenDiagnostic[];
80
+ /** Diagnostics left reported (review-required, or no safe edits). */
81
+ readonly skipped: readonly WardenDiagnostic[];
82
+ }
83
+
84
+ /**
85
+ * Apply the safe fixes among a file's diagnostics to its source.
86
+ *
87
+ * Pure and filesystem-free. Only `safety: 'safe'` fixes with edits are applied;
88
+ * everything else is returned in `skipped`. Edits from all applicable
89
+ * diagnostics are pooled and applied last-to-first in one pass.
90
+ */
91
+ export const applySafeFixesToSource = (
92
+ source: string,
93
+ diagnostics: readonly WardenDiagnostic[]
94
+ ): WardenFileFixResult => {
95
+ const applied: WardenDiagnostic[] = [];
96
+ const skipped: WardenDiagnostic[] = [];
97
+ const edits: ResolvedEdit[] = [];
98
+ const seenEdits = new Set<string>();
99
+
100
+ for (const diagnostic of diagnostics) {
101
+ if (hasSafeFixEdits(diagnostic)) {
102
+ applied.push(diagnostic);
103
+ for (const edit of diagnostic.fix.edits) {
104
+ const resolvedEdit = {
105
+ end: edit.end,
106
+ replacement: edit.replacement,
107
+ start: edit.start,
108
+ };
109
+ const key = `${String(resolvedEdit.start)}\0${String(resolvedEdit.end)}\0${resolvedEdit.replacement}`;
110
+ if (!seenEdits.has(key)) {
111
+ seenEdits.add(key);
112
+ edits.push(resolvedEdit);
113
+ }
114
+ }
115
+ } else {
116
+ skipped.push(diagnostic);
117
+ }
118
+ }
119
+
120
+ if (edits.length === 0) {
121
+ return { applied, changed: false, patched: source, skipped };
122
+ }
123
+
124
+ const patched = applyEdits(source, edits);
125
+ return { applied, changed: patched !== source, patched, skipped };
126
+ };