@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
package/src/config.ts ADDED
@@ -0,0 +1,184 @@
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 wardenConfigObjectSchema = z
14
+ .object({
15
+ apps: z.array(appNameSchema).min(1).optional(),
16
+ depth: z.enum(wardenDepthValues).default('all'),
17
+ drafts: z.enum(wardenDraftsValues).default('include'),
18
+ failOn: z.enum(wardenFailOnValues).default('error'),
19
+ format: z.enum(wardenFormatValues).default('summary'),
20
+ lock: z.enum(wardenLockValues).default('auto'),
21
+ })
22
+ .strict();
23
+
24
+ export const wardenConfigSchema = wardenConfigObjectSchema
25
+ .optional()
26
+ .transform((value) => wardenConfigObjectSchema.parse(value ?? {}));
27
+
28
+ export type WardenConfig = z.output<typeof wardenConfigSchema>;
29
+ export type WardenConfigInput = z.input<typeof wardenConfigSchema>;
30
+ export type WardenDepth = (typeof wardenDepthValues)[number];
31
+ export type WardenDraftsMode = (typeof wardenDraftsValues)[number];
32
+ export type WardenFailOn = (typeof wardenFailOnValues)[number];
33
+ export type WardenFormat = (typeof wardenFormatValues)[number];
34
+ export type WardenLockMode = (typeof wardenLockValues)[number];
35
+
36
+ export interface WardenConfigLayer extends Partial<WardenConfig> {
37
+ readonly noLockMutation?: boolean | undefined;
38
+ }
39
+
40
+ export interface EffectiveWardenConfig extends WardenConfig {
41
+ readonly noLockMutation: boolean;
42
+ }
43
+
44
+ export interface ResolveWardenConfigOptions {
45
+ readonly cli?: WardenConfigLayer | undefined;
46
+ readonly config?: WardenConfigInput | undefined;
47
+ readonly defaults?: Partial<WardenConfig> | undefined;
48
+ readonly env?: Record<string, string | undefined> | undefined;
49
+ }
50
+
51
+ export interface WardenConfigResolution {
52
+ readonly diagnostics: readonly WardenDiagnostic[];
53
+ readonly effectiveConfig: EffectiveWardenConfig;
54
+ }
55
+
56
+ const baseWardenConfig = (): WardenConfig => {
57
+ const omittedSection: unknown = undefined;
58
+ return wardenConfigSchema.parse(omittedSection);
59
+ };
60
+
61
+ const cleanUndefinedValues = <T extends Record<string, unknown>>(
62
+ value: T
63
+ ): Partial<T> =>
64
+ Object.fromEntries(
65
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
66
+ ) as Partial<T>;
67
+
68
+ const splitApps = (value: string): readonly string[] =>
69
+ value
70
+ .split(',')
71
+ .map((entry) => entry.trim())
72
+ .filter((entry) => entry.length > 0);
73
+
74
+ const readEnvLayer = (
75
+ env: Record<string, string | undefined>
76
+ ): Partial<WardenConfig> =>
77
+ cleanUndefinedValues({
78
+ apps: env['TRAILS_APPS'] ? splitApps(env['TRAILS_APPS']) : undefined,
79
+ depth: env['TRAILS_DEPTH'],
80
+ drafts: env['TRAILS_DRAFTS'],
81
+ failOn: env['TRAILS_FAIL_ON'],
82
+ format: env['TRAILS_FORMAT'],
83
+ lock: env['TRAILS_LOCK'],
84
+ }) as Partial<WardenConfig>;
85
+
86
+ const configDiagnostic = (message: string): WardenDiagnostic => ({
87
+ filePath: '<warden-config>',
88
+ line: 1,
89
+ message,
90
+ rule: 'warden-config',
91
+ severity: 'error',
92
+ });
93
+
94
+ const formatIssues = (error: z.ZodError): string =>
95
+ error.issues
96
+ .map((issue) => {
97
+ const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
98
+ return `${path}: ${issue.message}`;
99
+ })
100
+ .join('; ');
101
+
102
+ const parseConfigLayer = (
103
+ label: string,
104
+ value: WardenConfigInput | undefined
105
+ ): {
106
+ readonly data: Partial<WardenConfig>;
107
+ readonly diagnostics: readonly WardenDiagnostic[];
108
+ } => {
109
+ if (value === undefined) {
110
+ return { data: {}, diagnostics: [] };
111
+ }
112
+
113
+ const parsed = wardenConfigSchema.safeParse(value);
114
+ if (parsed.success) {
115
+ if (typeof value !== 'object' || value === null) {
116
+ return { data: parsed.data, diagnostics: [] };
117
+ }
118
+
119
+ return {
120
+ data: Object.fromEntries(
121
+ Object.keys(value).map((key) => [
122
+ key,
123
+ parsed.data[key as keyof WardenConfig],
124
+ ])
125
+ ) as Partial<WardenConfig>,
126
+ diagnostics: [],
127
+ };
128
+ }
129
+
130
+ return {
131
+ data: {},
132
+ diagnostics: [
133
+ configDiagnostic(
134
+ `Invalid ${label} Warden config: ${formatIssues(parsed.error)}`
135
+ ),
136
+ ],
137
+ };
138
+ };
139
+
140
+ export const resolveWardenConfig = ({
141
+ cli,
142
+ config,
143
+ defaults,
144
+ env = {},
145
+ }: ResolveWardenConfigOptions = {}): WardenConfigResolution => {
146
+ const { noLockMutation = false, ...cliConfig } = cli ?? {};
147
+ const defaultLayer = wardenConfigSchema.parse({
148
+ ...baseWardenConfig(),
149
+ ...defaults,
150
+ });
151
+ const configLayer = parseConfigLayer('file', config);
152
+ const envLayer = parseConfigLayer('environment', readEnvLayer(env));
153
+ const merged = {
154
+ ...defaultLayer,
155
+ ...configLayer.data,
156
+ ...envLayer.data,
157
+ ...cleanUndefinedValues(cliConfig),
158
+ };
159
+ const parsed = wardenConfigSchema.safeParse(merged);
160
+ const diagnostics = [...configLayer.diagnostics, ...envLayer.diagnostics];
161
+
162
+ if (!parsed.success) {
163
+ return {
164
+ diagnostics: [
165
+ ...diagnostics,
166
+ configDiagnostic(
167
+ `Invalid effective Warden config: ${formatIssues(parsed.error)}`
168
+ ),
169
+ ],
170
+ effectiveConfig: {
171
+ ...defaultLayer,
172
+ noLockMutation,
173
+ },
174
+ };
175
+ }
176
+
177
+ return {
178
+ diagnostics,
179
+ effectiveConfig: {
180
+ ...parsed.data,
181
+ noLockMutation,
182
+ },
183
+ };
184
+ };
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,32 +1,82 @@
1
1
  /**
2
- * Surface lock drift detection.
2
+ * Topo 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 `topo.lock` artifact hash listed in `trails.lock` against a
5
+ * freshly generated TopoGraph hash to detect when the trail topology has
6
+ * changed without updating the artifact family.
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
+ createTopoStore,
19
+ deriveTopoGraph,
20
+ deriveTopoGraphHash,
21
+ isTopoArtifactRegenerationError,
22
+ readLockManifest,
23
+ } from '@ontrails/topographer';
24
+ import type { LockManifest } from '@ontrails/topographer';
15
25
 
16
26
  /**
17
- * Result of a drift check comparing committed surface.lock against the current state.
27
+ * Result of a drift check comparing committed trails.lock against the current state.
18
28
  */
19
29
  export interface DriftResult {
30
+ /** Why drift could not be computed for the established graph, when blocked. */
31
+ readonly blockedReason?: string | undefined;
20
32
  /** Whether the committed lock is out of date */
21
33
  readonly stale: boolean;
22
- /** Hash from the committed surface.lock file, or null if not found */
34
+ /** Hash from the committed trails.lock file, or null if not found */
23
35
  readonly committedHash: string | null;
24
36
  /** Hash computed from the current trail topology */
25
37
  readonly currentHash: string;
26
38
  }
27
39
 
40
+ interface BlockedLockRead {
41
+ readonly drift: DriftResult;
42
+ readonly kind: 'blocked-lock-read';
43
+ }
44
+
45
+ const blockedDrift = (reason: string): DriftResult => ({
46
+ blockedReason: reason,
47
+ committedHash: null,
48
+ currentHash: 'blocked',
49
+ stale: true,
50
+ });
51
+
52
+ const blockedLockRead = (reason: string): BlockedLockRead => ({
53
+ drift: blockedDrift(reason),
54
+ kind: 'blocked-lock-read',
55
+ });
56
+
57
+ const readCommittedLockManifest = async (
58
+ rootDir: string
59
+ ): Promise<BlockedLockRead | LockManifest | null> => {
60
+ const trailsDir = deriveTrailsDir({ rootDir });
61
+ try {
62
+ return existsSync(rootDir) && statSync(rootDir).isDirectory()
63
+ ? await readLockManifest({ dir: trailsDir })
64
+ : null;
65
+ } catch (error) {
66
+ if (isTopoArtifactRegenerationError(error)) {
67
+ return blockedLockRead(error.message);
68
+ }
69
+ throw error;
70
+ }
71
+ };
72
+
73
+ const isBlockedLockRead = (
74
+ result: BlockedLockRead | LockManifest | null
75
+ ): result is BlockedLockRead =>
76
+ result !== null && 'kind' in result && result.kind === 'blocked-lock-read';
77
+
28
78
  /**
29
- * Check whether the committed surface.lock is stale compared to the current topology.
79
+ * Check whether the committed trails.lock is stale compared to the current topology.
30
80
  *
31
81
  * When no topo is provided, returns a clean result (no drift detectable without runtime info).
32
82
  */
@@ -34,17 +84,51 @@ export const checkDrift = async (
34
84
  rootDir: string,
35
85
  topo?: Topo | undefined
36
86
  ): Promise<DriftResult> => {
37
- if (!topo) {
38
- return { committedHash: null, currentHash: 'unknown', stale: false };
39
- }
87
+ try {
88
+ const lockManifest = await readCommittedLockManifest(rootDir);
89
+ if (isBlockedLockRead(lockManifest)) {
90
+ return lockManifest.drift;
91
+ }
92
+ const topoArtifact =
93
+ lockManifest?.artifacts.find(
94
+ (artifact) => artifact.role === 'topo' && artifact.path === 'topo.lock'
95
+ ) ?? null;
96
+ if (lockManifest !== null && topoArtifact === null) {
97
+ return blockedDrift(
98
+ 'trails.lock does not contain a topo.lock artifact. Regenerate with `trails compile`.'
99
+ );
100
+ }
101
+ const readStoredHash = (): string | undefined => {
102
+ try {
103
+ return createTopoStore({ rootDir }).exports.get()?.topoGraphHash;
104
+ } catch (error) {
105
+ if (error instanceof NotFoundError) {
106
+ return;
107
+ }
108
+ throw error;
109
+ }
110
+ };
111
+ const currentHash =
112
+ topo === undefined
113
+ ? (readStoredHash() ?? 'unknown')
114
+ : deriveTopoGraphHash(deriveTopoGraph(topo));
40
115
 
41
- const surfaceMap = generateSurfaceMap(topo);
42
- const currentHash = hashSurfaceMap(surfaceMap);
43
- const committedHash = await readSurfaceLock({ dir: rootDir });
116
+ return {
117
+ committedHash: topoArtifact?.sha256 ?? null,
118
+ currentHash,
119
+ stale:
120
+ topoArtifact !== null &&
121
+ currentHash !== 'unknown' &&
122
+ topoArtifact.sha256 !== currentHash,
123
+ };
124
+ } catch (error) {
125
+ if (
126
+ !(error instanceof ValidationError) &&
127
+ !isTopoArtifactRegenerationError(error)
128
+ ) {
129
+ throw error;
130
+ }
44
131
 
45
- return {
46
- committedHash,
47
- currentHash,
48
- stale: committedHash !== null && committedHash !== currentHash,
49
- };
132
+ return blockedDrift(error.message);
133
+ }
50
134
  };
package/src/fix.ts ADDED
@@ -0,0 +1,120 @@
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
+
99
+ for (const diagnostic of diagnostics) {
100
+ if (hasSafeFixEdits(diagnostic)) {
101
+ applied.push(diagnostic);
102
+ for (const edit of diagnostic.fix.edits) {
103
+ edits.push({
104
+ end: edit.end,
105
+ replacement: edit.replacement,
106
+ start: edit.start,
107
+ });
108
+ }
109
+ } else {
110
+ skipped.push(diagnostic);
111
+ }
112
+ }
113
+
114
+ if (edits.length === 0) {
115
+ return { applied, changed: false, patched: source, skipped };
116
+ }
117
+
118
+ const patched = applyEdits(source, edits);
119
+ return { applied, changed: patched !== source, patched, skipped };
120
+ };
package/src/formatters.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { WardenReport } from './cli.js';
10
- import type { WardenSeverity } from './rules/types.js';
10
+ import type { WardenGuidanceLink, WardenSeverity } from './rules/types.js';
11
11
 
12
12
  /** Map warden severity to GitHub Actions annotation level. */
13
13
  const ghLevel: Record<WardenSeverity, string> = {
@@ -19,7 +19,8 @@ const ghLevel: Record<WardenSeverity, string> = {
19
19
  * Produce GitHub Actions workflow command annotations, one per diagnostic.
20
20
  *
21
21
  * Severity mapping: `error` to `::error`, `warn` to `::warning`.
22
- * Drift staleness is emitted as a single `::error` annotation when detected.
22
+ * Drift staleness or established-export blocking is emitted as a single
23
+ * `::error` annotation when detected.
23
24
  */
24
25
  export const formatGitHubAnnotations = (report: WardenReport): string => {
25
26
  const lines: string[] = [];
@@ -31,9 +32,11 @@ export const formatGitHubAnnotations = (report: WardenReport): string => {
31
32
  );
32
33
  }
33
34
 
34
- if (report.drift?.stale) {
35
+ if (report.drift?.blockedReason !== undefined) {
36
+ lines.push(`::error::drift: ${report.drift.blockedReason}`);
37
+ } else if (report.drift?.stale) {
35
38
  lines.push(
36
- '::error::drift: surface.lock is stale (regenerate with `trails survey generate`)'
39
+ '::error::drift: trails.lock is stale (regenerate with `trails compile`)'
37
40
  );
38
41
  }
39
42
 
@@ -57,6 +60,7 @@ export const formatJson = (report: WardenReport): string => {
57
60
  {
58
61
  diagnostics: report.diagnostics,
59
62
  drift: report.drift,
63
+ fixes: report.fixes,
60
64
  passed: report.passed,
61
65
  summary,
62
66
  },
@@ -65,9 +69,56 @@ export const formatJson = (report: WardenReport): string => {
65
69
  );
66
70
  };
67
71
 
68
- /** Format a diagnostic as a markdown list item. */
69
- const diagnosticLine = (d: WardenReport['diagnostics'][number]): string =>
70
- `- \`${d.filePath}:${String(d.line)}\` — ${d.rule}: ${d.message}`;
72
+ const formatGuidanceLink = (link: WardenGuidanceLink): string => {
73
+ if (link.path !== undefined) {
74
+ return `[${link.label}](${link.path})`;
75
+ }
76
+ if (link.url !== undefined) {
77
+ return `[${link.label}](${link.url})`;
78
+ }
79
+ return link.label;
80
+ };
81
+
82
+ /** Format diagnostic guidance as indented markdown lines. */
83
+ const diagnosticGuidanceLines = (
84
+ d: WardenReport['diagnostics'][number]
85
+ ): readonly string[] => {
86
+ const { guidance } = d;
87
+ if (guidance === undefined) {
88
+ return [];
89
+ }
90
+
91
+ const lines = [` - Next: ${guidance.summary}`];
92
+ if (guidance.steps !== undefined && guidance.steps.length > 0) {
93
+ lines.push(
94
+ ` - Steps: ${guidance.steps
95
+ .map((step, index) => `${String(index + 1)}. ${step}`)
96
+ .join(' ')}`
97
+ );
98
+ }
99
+ if (guidance.docs !== undefined && guidance.docs.length > 0) {
100
+ lines.push(` - Docs: ${guidance.docs.map(formatGuidanceLink).join(', ')}`);
101
+ }
102
+ if (guidance.commands !== undefined && guidance.commands.length > 0) {
103
+ lines.push(
104
+ ` - Commands: ${guidance.commands.map((cmd) => `\`${cmd}\``).join(', ')}`
105
+ );
106
+ }
107
+ if (guidance.relatedRules !== undefined && guidance.relatedRules.length > 0) {
108
+ lines.push(
109
+ ` - Related: ${guidance.relatedRules.map((rule) => `\`${rule}\``).join(', ')}`
110
+ );
111
+ }
112
+ return lines;
113
+ };
114
+
115
+ /** Format a diagnostic as markdown lines. */
116
+ const diagnosticLines = (
117
+ d: WardenReport['diagnostics'][number]
118
+ ): readonly string[] => [
119
+ `- \`${d.filePath}:${String(d.line)}\` — ${d.rule}: ${d.message}`,
120
+ ...diagnosticGuidanceLines(d),
121
+ ];
71
122
 
72
123
  /** Render a severity group as a headed markdown section, or empty array. */
73
124
  const severitySection = (
@@ -77,18 +128,36 @@ const severitySection = (
77
128
  if (diagnostics.length === 0) {
78
129
  return [];
79
130
  }
80
- return ['', `### ${heading}`, ...diagnostics.map(diagnosticLine)];
131
+ return ['', `### ${heading}`, ...diagnostics.flatMap(diagnosticLines)];
81
132
  };
82
133
 
83
134
  /** Render a drift section if stale, otherwise empty array. */
84
135
  const driftSection = (drift: WardenReport['drift']): readonly string[] => {
136
+ if (drift?.blockedReason !== undefined) {
137
+ return [
138
+ '',
139
+ '### Drift',
140
+ `- established exports are blocked: ${drift.blockedReason}`,
141
+ ];
142
+ }
143
+
85
144
  if (!drift?.stale) {
86
145
  return [];
87
146
  }
88
147
  return [
89
148
  '',
90
149
  '### Drift',
91
- '- surface.lock is stale (regenerate with `trails survey generate`)',
150
+ '- trails.lock is stale (regenerate with `trails compile`)',
151
+ ];
152
+ };
153
+
154
+ /** Render safe-fix counts when a fix pass was requested. */
155
+ const fixSummaryLine = (fixes: WardenReport['fixes']): readonly string[] => {
156
+ if (fixes === undefined) {
157
+ return [];
158
+ }
159
+ return [
160
+ `**Fixes:** ${String(fixes.applied)} applied, ${String(fixes.filesChanged)} files changed, ${String(fixes.skipped)} skipped`,
92
161
  ];
93
162
  };
94
163
 
@@ -106,6 +175,7 @@ export const formatSummary = (report: WardenReport): string => {
106
175
  '## Warden Report',
107
176
  '',
108
177
  `**Result: ${result}** | ${String(report.errorCount)} errors, ${String(report.warnCount)} warnings`,
178
+ ...fixSummaryLine(report.fixes),
109
179
  ...severitySection('Errors', errors),
110
180
  ...severitySection('Warnings', warnings),
111
181
  ...driftSection(report.drift),