@ontrails/warden 0.2.0

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 (188) hide show
  1. package/CHANGELOG.md +1556 -0
  2. package/README.md +200 -0
  3. package/bin/warden.ts +51 -0
  4. package/package.json +51 -0
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +2159 -0
  7. package/src/command.ts +1167 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +260 -0
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +178 -0
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +295 -0
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/regrade-history.ts +188 -0
  18. package/src/regrade-receipt.ts +484 -0
  19. package/src/resolve.ts +531 -0
  20. package/src/rules/activation-orphan.ts +97 -0
  21. package/src/rules/captured-kernel.ts +375 -0
  22. package/src/rules/circular-refs.ts +150 -0
  23. package/src/rules/cli-command-route-coherence.ts +177 -0
  24. package/src/rules/composes-declarations.ts +839 -0
  25. package/src/rules/context-no-surface-types.ts +214 -0
  26. package/src/rules/dead-internal-trail.ts +161 -0
  27. package/src/rules/dead-public-trail.ts +258 -0
  28. package/src/rules/draft-file-marking.ts +155 -0
  29. package/src/rules/draft-visible-debt.ts +83 -0
  30. package/src/rules/duplicate-exported-symbol.ts +172 -0
  31. package/src/rules/duplicate-public-contract.ts +137 -0
  32. package/src/rules/entity-exists.ts +254 -0
  33. package/src/rules/entity-ids.ts +15 -0
  34. package/src/rules/error-mapping-completeness.ts +290 -0
  35. package/src/rules/example-valid.ts +395 -0
  36. package/src/rules/fires-declarations.ts +740 -0
  37. package/src/rules/governed-symbol-residue.ts +535 -0
  38. package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
  39. package/src/rules/implementation-returns-result.ts +1733 -0
  40. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  41. package/src/rules/incomplete-crud.ts +583 -0
  42. package/src/rules/index.ts +328 -0
  43. package/src/rules/intent-propagation.ts +125 -0
  44. package/src/rules/layer-field-name-drift.ts +102 -0
  45. package/src/rules/library-render-coherence.ts +97 -0
  46. package/src/rules/metadata.ts +929 -0
  47. package/src/rules/missing-reconcile.ts +97 -0
  48. package/src/rules/missing-visibility.ts +111 -0
  49. package/src/rules/no-destructured-compose.ts +196 -0
  50. package/src/rules/no-dev-permit-in-source.ts +99 -0
  51. package/src/rules/no-direct-implementation-call.ts +52 -0
  52. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  53. package/src/rules/no-legacy-layer-imports.ts +211 -0
  54. package/src/rules/no-native-error-result.ts +118 -0
  55. package/src/rules/no-redundant-result-error-wrap.ts +382 -0
  56. package/src/rules/no-retired-cross-vocabulary.ts +203 -0
  57. package/src/rules/no-sync-result-assumption.ts +1199 -0
  58. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  59. package/src/rules/no-throw-in-implementation.ts +48 -0
  60. package/src/rules/no-top-level-surface.ts +371 -0
  61. package/src/rules/on-references-exist.ts +194 -0
  62. package/src/rules/orphaned-signal.ts +149 -0
  63. package/src/rules/owner-render-parity.ts +143 -0
  64. package/src/rules/permit-governance.ts +25 -0
  65. package/src/rules/prefer-schema-inference.ts +141 -0
  66. package/src/rules/public-export-example-coverage.ts +561 -0
  67. package/src/rules/public-internal-deep-imports.ts +454 -0
  68. package/src/rules/public-output-schema.ts +29 -0
  69. package/src/rules/public-union-output-discriminants.ts +150 -0
  70. package/src/rules/read-intent-fires.ts +188 -0
  71. package/src/rules/reference-exists.ts +97 -0
  72. package/src/rules/registry-names.ts +171 -0
  73. package/src/rules/resolved-import-boundary.ts +146 -0
  74. package/src/rules/resource-declarations.ts +697 -0
  75. package/src/rules/resource-exists.ts +181 -0
  76. package/src/rules/resource-id-grammar.ts +65 -0
  77. package/src/rules/resource-mock-coverage.ts +115 -0
  78. package/src/rules/retired-vocabulary.ts +1407 -0
  79. package/src/rules/scan.ts +59 -0
  80. package/src/rules/scheduled-destroy-intent.ts +44 -0
  81. package/src/rules/signal-graph-coaching.ts +220 -0
  82. package/src/rules/source/composition.ts +165 -0
  83. package/src/rules/source/drafts.ts +164 -0
  84. package/src/rules/source/entities.ts +618 -0
  85. package/src/rules/source/pragmas.ts +45 -0
  86. package/src/rules/source/resources.ts +64 -0
  87. package/src/rules/source/signals.ts +397 -0
  88. package/src/rules/source/stores.ts +310 -0
  89. package/src/rules/specs.ts +388 -0
  90. package/src/rules/static-resource-accessor-preference.ts +654 -0
  91. package/src/rules/structure.ts +234 -0
  92. package/src/rules/surface-overlay-coherence.ts +262 -0
  93. package/src/rules/surface-trailhead-coherence.ts +366 -0
  94. package/src/rules/trail-fork-coaching.ts +625 -0
  95. package/src/rules/trail-versioning-source.ts +1076 -0
  96. package/src/rules/trail-versioning-topo.ts +172 -0
  97. package/src/rules/trailhead-override-divergence.ts +356 -0
  98. package/src/rules/types.ts +464 -0
  99. package/src/rules/unmaterialized-activation-source.ts +85 -0
  100. package/src/rules/unreachable-detour-shadowing.ts +343 -0
  101. package/src/rules/valid-describe-refs.ts +224 -0
  102. package/src/rules/valid-detour-contract.ts +78 -0
  103. package/src/rules/warden-export-symmetry.ts +540 -0
  104. package/src/rules/warden-rules-use-ast.ts +1109 -0
  105. package/src/rules/webhook-route-collision.ts +306 -0
  106. package/src/rules/workspace-lock-ownership.ts +34 -0
  107. package/src/trails/activation-orphan.trail.ts +84 -0
  108. package/src/trails/captured-kernel.trail.ts +108 -0
  109. package/src/trails/circular-refs.trail.ts +29 -0
  110. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  111. package/src/trails/composes-declarations.trail.ts +22 -0
  112. package/src/trails/context-no-surface-types.trail.ts +21 -0
  113. package/src/trails/dead-internal-trail.trail.ts +26 -0
  114. package/src/trails/dead-public-trail.trail.ts +31 -0
  115. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  116. package/src/trails/draft-file-marking.trail.ts +16 -0
  117. package/src/trails/draft-visible-debt.trail.ts +16 -0
  118. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  119. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  120. package/src/trails/entity-exists.trail.ts +21 -0
  121. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  122. package/src/trails/example-valid.trail.ts +25 -0
  123. package/src/trails/fires-declarations.trail.ts +23 -0
  124. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  125. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  126. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  127. package/src/trails/implementation-returns-result.trail.ts +20 -0
  128. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  129. package/src/trails/incomplete-crud.trail.ts +39 -0
  130. package/src/trails/index.ts +91 -0
  131. package/src/trails/intent-propagation.trail.ts +30 -0
  132. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  133. package/src/trails/library-render-coherence.trail.ts +43 -0
  134. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  135. package/src/trails/missing-reconcile.trail.ts +33 -0
  136. package/src/trails/missing-visibility.trail.ts +22 -0
  137. package/src/trails/no-destructured-compose.trail.ts +44 -0
  138. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  139. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  140. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  141. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  142. package/src/trails/no-native-error-result.trail.ts +18 -0
  143. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  144. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  145. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  146. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  147. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  148. package/src/trails/no-top-level-surface.trail.ts +43 -0
  149. package/src/trails/on-references-exist.trail.ts +21 -0
  150. package/src/trails/orphaned-signal.trail.ts +36 -0
  151. package/src/trails/owner-render-parity.trail.ts +26 -0
  152. package/src/trails/pending-force.trail.ts +21 -0
  153. package/src/trails/permit-governance.trail.ts +51 -0
  154. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  155. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  156. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  157. package/src/trails/public-output-schema.trail.ts +55 -0
  158. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  159. package/src/trails/read-intent-fires.trail.ts +20 -0
  160. package/src/trails/reference-exists.trail.ts +25 -0
  161. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  162. package/src/trails/resource-declarations.trail.ts +25 -0
  163. package/src/trails/resource-exists.trail.ts +27 -0
  164. package/src/trails/resource-id-grammar.trail.ts +39 -0
  165. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  166. package/src/trails/run.ts +205 -0
  167. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  168. package/src/trails/schema.ts +295 -0
  169. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  170. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  171. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  172. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  173. package/src/trails/topo.ts +6 -0
  174. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  175. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  176. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  177. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  178. package/src/trails/valid-describe-refs.trail.ts +18 -0
  179. package/src/trails/valid-detour-contract.trail.ts +71 -0
  180. package/src/trails/version-gap.trail.ts +35 -0
  181. package/src/trails/version-pinned-compose.trail.ts +23 -0
  182. package/src/trails/version-without-examples.trail.ts +38 -0
  183. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  184. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  185. package/src/trails/webhook-route-collision.trail.ts +50 -0
  186. package/src/trails/workspace-lock-ownership.trail.ts +16 -0
  187. package/src/trails/wrap-rule.ts +269 -0
  188. package/src/workspaces.ts +264 -0
@@ -0,0 +1,535 @@
1
+ import {
2
+ getNodeKey,
3
+ getNodeValueNode,
4
+ identifierName,
5
+ offsetToLine,
6
+ parseWithDiagnostics,
7
+ walkWithScopeContext,
8
+ } from '@ontrails/source';
9
+ import type { AstNode, AstScopeContext } from '@ontrails/source';
10
+ import { listGovernedVocabularyTransitions } from './retired-vocabulary.js';
11
+ import type {
12
+ GovernedVocabularySymbolRename,
13
+ GovernedVocabularyTransition,
14
+ } from './retired-vocabulary.js';
15
+ import type {
16
+ GovernedVocabularyHistoryEvidence,
17
+ ProjectAwareWardenRule,
18
+ ProjectContext,
19
+ WardenDiagnostic,
20
+ WardenFix,
21
+ } from './types.js';
22
+
23
+ const RULE_NAME = 'governed-symbol-residue';
24
+
25
+ const ACTIVE_STATUSES = new Set<GovernedVocabularyTransition['status']>([
26
+ 'active',
27
+ 'complete',
28
+ ]);
29
+
30
+ const FIX_OWNED_TRANSITION_IDS = new Set(['cross-compose']);
31
+
32
+ const ALLOWED_SOURCE_PATH_SUFFIXES = [
33
+ '/packages/warden/src/rules/governed-symbol-residue.ts',
34
+ '/packages/warden/src/rules/retired-vocabulary.ts',
35
+ ] as const;
36
+
37
+ const normalizePath = (filePath: string): string =>
38
+ filePath.replaceAll('\\', '/');
39
+
40
+ const isAllowedSourcePath = (filePath: string): boolean =>
41
+ ALLOWED_SOURCE_PATH_SUFFIXES.some((suffix) =>
42
+ normalizePath(filePath).endsWith(suffix)
43
+ );
44
+
45
+ interface ActiveSymbolRename {
46
+ readonly history?: GovernedVocabularyHistoryEvidence;
47
+ readonly rename: GovernedVocabularySymbolRename;
48
+ readonly targetSegments: readonly string[];
49
+ readonly transition: GovernedVocabularyTransition;
50
+ }
51
+
52
+ const activeSymbolRenames = (
53
+ context?: ProjectContext
54
+ ): readonly ActiveSymbolRename[] =>
55
+ listGovernedVocabularyTransitions()
56
+ .filter(
57
+ (transition) =>
58
+ ACTIVE_STATUSES.has(transition.status) &&
59
+ !FIX_OWNED_TRANSITION_IDS.has(transition.id)
60
+ )
61
+ .flatMap((transition) => {
62
+ const targetSegments = [
63
+ ...new Set(transition.symbolRenames.map((rename) => rename.to)),
64
+ ];
65
+ const history = context?.governedVocabularyHistoryByTransitionId?.get(
66
+ transition.id
67
+ );
68
+ return transition.symbolRenames.map((rename) => ({
69
+ ...(history === undefined ? {} : { history }),
70
+ rename,
71
+ targetSegments,
72
+ transition,
73
+ }));
74
+ });
75
+
76
+ const isIdentifierNamed = (node: AstNode, name: string): boolean =>
77
+ node.type === 'Identifier' && identifierName(node) === name;
78
+
79
+ const identifierTokenSpan = (
80
+ node: AstNode,
81
+ sourceCode: string,
82
+ name: string
83
+ ): { readonly end: number; readonly start: number } | null => {
84
+ const end = node.start + name.length;
85
+ return sourceCode.slice(node.start, end) === name
86
+ ? { end, start: node.start }
87
+ : null;
88
+ };
89
+
90
+ const toPascalIdentifierSegment = (value: string): string =>
91
+ value.length === 0
92
+ ? value
93
+ : `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}`;
94
+
95
+ const isScreamingSnakeIdentifier = (name: string): boolean =>
96
+ /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(name) && name.includes('_');
97
+
98
+ const replaceScreamingSnakeIdentifierSegment = (
99
+ name: string,
100
+ from: string,
101
+ to: string
102
+ ): string | null => {
103
+ if (!isScreamingSnakeIdentifier(name)) {
104
+ return null;
105
+ }
106
+
107
+ const fromSegment = from.toUpperCase();
108
+ const toSegment = to.toUpperCase();
109
+ let changed = false;
110
+ const nextName = name
111
+ .split('_')
112
+ .map((segment) => {
113
+ if (segment !== fromSegment) {
114
+ return segment;
115
+ }
116
+ changed = true;
117
+ return toSegment;
118
+ })
119
+ .join('_');
120
+
121
+ return changed ? nextName : null;
122
+ };
123
+
124
+ const replaceCamelOrPascalIdentifierSegment = (
125
+ name: string,
126
+ from: string,
127
+ to: string
128
+ ): string | null => {
129
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(name)) {
130
+ return null;
131
+ }
132
+
133
+ const fromPascalSegment = toPascalIdentifierSegment(from);
134
+ const toPascalSegment = toPascalIdentifierSegment(to);
135
+ const segmentPattern = /[A-Z]+(?=[A-Z][a-z]|$)|[A-Z]?[a-z]+|[0-9]+/g;
136
+ let cursor = 0;
137
+ let changed = false;
138
+ let nextName = '';
139
+
140
+ for (const match of name.matchAll(segmentPattern)) {
141
+ const [segment] = match;
142
+ const { index } = match;
143
+ if (index === undefined || index !== cursor) {
144
+ return null;
145
+ }
146
+
147
+ if (segment === from) {
148
+ changed = true;
149
+ nextName += to;
150
+ } else if (segment === fromPascalSegment) {
151
+ changed = true;
152
+ nextName += toPascalSegment;
153
+ } else {
154
+ nextName += segment;
155
+ }
156
+
157
+ cursor = index + segment.length;
158
+ }
159
+
160
+ if (cursor !== name.length || !changed) {
161
+ return null;
162
+ }
163
+
164
+ return nextName;
165
+ };
166
+
167
+ const deriveIdentifierSegmentTarget = (
168
+ name: string,
169
+ from: string,
170
+ to: string
171
+ ): string | null => {
172
+ const leadingUnderscores = /^_+/.exec(name)?.[0] ?? '';
173
+ const coreName =
174
+ leadingUnderscores.length > 0
175
+ ? name.slice(leadingUnderscores.length)
176
+ : name;
177
+ if (coreName.length === 0) {
178
+ return null;
179
+ }
180
+
181
+ const screamingSnakeTarget = replaceScreamingSnakeIdentifierSegment(
182
+ coreName,
183
+ from,
184
+ to
185
+ );
186
+ if (screamingSnakeTarget !== null) {
187
+ return `${leadingUnderscores}${screamingSnakeTarget}`;
188
+ }
189
+
190
+ const camelOrPascalTarget = replaceCamelOrPascalIdentifierSegment(
191
+ coreName,
192
+ from,
193
+ to
194
+ );
195
+ return camelOrPascalTarget === null
196
+ ? null
197
+ : `${leadingUnderscores}${camelOrPascalTarget}`;
198
+ };
199
+
200
+ const hasIdentifierSegment = (name: string, segment: string): boolean =>
201
+ deriveIdentifierSegmentTarget(name, segment, segment) !== null;
202
+
203
+ interface SymbolRenameMatch {
204
+ readonly from: string;
205
+ readonly rename: GovernedVocabularySymbolRename;
206
+ readonly span: { readonly end: number; readonly start: number } | null;
207
+ readonly to: string;
208
+ }
209
+
210
+ const resolveSymbolRenameMatch = (
211
+ node: AstNode,
212
+ sourceCode: string,
213
+ rename: GovernedVocabularySymbolRename
214
+ ): SymbolRenameMatch | null => {
215
+ if (rename.match === 'exact') {
216
+ if (!isIdentifierNamed(node, rename.from)) {
217
+ return null;
218
+ }
219
+
220
+ return {
221
+ from: rename.from,
222
+ rename,
223
+ span: identifierTokenSpan(node, sourceCode, rename.from),
224
+ to: rename.to,
225
+ };
226
+ }
227
+
228
+ if (node.type !== 'Identifier') {
229
+ return null;
230
+ }
231
+
232
+ const name = identifierName(node);
233
+ if (name === null) {
234
+ return null;
235
+ }
236
+ const target = deriveIdentifierSegmentTarget(name, rename.from, rename.to);
237
+ if (target === null) {
238
+ return null;
239
+ }
240
+
241
+ return {
242
+ from: name,
243
+ rename,
244
+ span: identifierTokenSpan(node, sourceCode, name),
245
+ to: target,
246
+ };
247
+ };
248
+
249
+ const authoredPropertyKeyParentTypes = new Set([
250
+ 'AccessorProperty',
251
+ 'MethodDefinition',
252
+ 'Property',
253
+ 'PropertyDefinition',
254
+ 'TSAbstractMethodDefinition',
255
+ 'TSAbstractPropertyDefinition',
256
+ 'TSMethodSignature',
257
+ 'TSPropertySignature',
258
+ ]);
259
+
260
+ const isAuthoredMemberName = (context: AstScopeContext): boolean =>
261
+ (context.key === 'key' &&
262
+ context.parent !== null &&
263
+ context.parent !== undefined &&
264
+ authoredPropertyKeyParentTypes.has(context.parent.type)) ||
265
+ (context.key === 'parameter' &&
266
+ context.parent?.type === 'TSParameterProperty');
267
+
268
+ const isModuleBoundaryName = (context: AstScopeContext): boolean =>
269
+ context.parent?.type === 'ImportSpecifier' ||
270
+ context.parent?.type === 'ImportDefaultSpecifier' ||
271
+ context.parent?.type === 'ImportNamespaceSpecifier' ||
272
+ context.parent?.type === 'ExportSpecifier';
273
+
274
+ const reviewReasonFor = (
275
+ context: AstScopeContext,
276
+ match: SymbolRenameMatch,
277
+ shorthandIdentifiers: ReadonlySet<string>,
278
+ targetSegments: readonly string[]
279
+ ): string | null => {
280
+ if (match.rename.match === 'identifier-segment') {
281
+ const existingTarget = targetSegments.find((segment) =>
282
+ hasIdentifierSegment(match.from, segment)
283
+ );
284
+ if (existingTarget !== undefined) {
285
+ return `already contains target segment '${existingTarget}'`;
286
+ }
287
+ }
288
+
289
+ if (match.rename.safety === 'review') {
290
+ return 'requires semantic classification';
291
+ }
292
+
293
+ if (shorthandIdentifiers.has(match.from)) {
294
+ return 'participates in an authored shorthand property';
295
+ }
296
+
297
+ const declaration = context.getDeclaration(match.from);
298
+ if (
299
+ declaration &&
300
+ match.rename.reviewDeclarationTypes.includes(declaration.type)
301
+ ) {
302
+ return `resolves to ${declaration.type}`;
303
+ }
304
+
305
+ if (isModuleBoundaryName(context)) {
306
+ return 'names an import or export boundary';
307
+ }
308
+
309
+ if (
310
+ context.key === 'property' &&
311
+ (context.parent?.type === 'MemberExpression' ||
312
+ context.parent?.type === 'StaticMemberExpression')
313
+ ) {
314
+ return 'is a member property with no governed declaration';
315
+ }
316
+
317
+ if (isAuthoredMemberName(context)) {
318
+ return 'is an authored property key';
319
+ }
320
+
321
+ return null;
322
+ };
323
+
324
+ const isDuplicateShorthandValueVisit = (
325
+ node: AstNode,
326
+ context: AstScopeContext
327
+ ): boolean => {
328
+ if (context.key !== 'value' || context.parent?.type !== 'Property') {
329
+ return false;
330
+ }
331
+
332
+ const key = getNodeKey(context.parent);
333
+ return key?.start === node.start && key.end === node.end;
334
+ };
335
+
336
+ const isShorthandPropertyKeyVisit = (
337
+ node: AstNode,
338
+ context: AstScopeContext
339
+ ): boolean => {
340
+ if (context.key !== 'key' || context.parent?.type !== 'Property') {
341
+ return false;
342
+ }
343
+
344
+ const value = getNodeValueNode(context.parent);
345
+ return value?.start === node.start && value.end === node.end;
346
+ };
347
+
348
+ const reviewFixFor = (
349
+ match: SymbolRenameMatch,
350
+ reviewReason: string
351
+ ): WardenFix => ({
352
+ class: 'term-rewrite',
353
+ reason: `Retired governed symbol '${match.from}' ${reviewReason}; review before migrating to '${match.to}'.`,
354
+ safety: 'review',
355
+ });
356
+
357
+ const safeFixFor = (match: SymbolRenameMatch): WardenFix | undefined => {
358
+ if (match.span === null) {
359
+ return undefined;
360
+ }
361
+ return {
362
+ class: 'term-rewrite',
363
+ edits: [
364
+ {
365
+ end: match.span.end,
366
+ replacement: match.to,
367
+ start: match.span.start,
368
+ },
369
+ ],
370
+ reason: `Retired governed symbol '${match.from}' has a mechanical replacement '${match.to}'.`,
371
+ safety: 'safe',
372
+ };
373
+ };
374
+
375
+ const diagnosticMessageFor = (
376
+ match: SymbolRenameMatch,
377
+ transition?: GovernedVocabularyTransition,
378
+ history?: GovernedVocabularyHistoryEvidence
379
+ ): string => {
380
+ if (history !== undefined) {
381
+ return `Retired governed symbol '${match.from}' was reintroduced after governed transition '${history.transitionId}' in '${history.path}' (${history.id}); migrate to '${match.to}'.`;
382
+ }
383
+ if (transition?.provenance.mode === 'regrade-history') {
384
+ return `Retired governed symbol '${match.from}' belongs to governed transition '${transition.id}', but no committed Regrade history proves the migration ran.`;
385
+ }
386
+ return `Retired governed symbol '${match.from}' should migrate to '${match.to}'.`;
387
+ };
388
+
389
+ const diagnosticFor = (
390
+ sourceCode: string,
391
+ filePath: string,
392
+ node: AstNode,
393
+ match: SymbolRenameMatch,
394
+ reviewReason: string | null,
395
+ transition?: GovernedVocabularyTransition,
396
+ history?: GovernedVocabularyHistoryEvidence
397
+ ): WardenDiagnostic => {
398
+ const fix =
399
+ reviewReason === null
400
+ ? safeFixFor(match)
401
+ : reviewFixFor(match, reviewReason);
402
+ return {
403
+ filePath,
404
+ ...(fix === undefined ? {} : { fix }),
405
+ line: offsetToLine(sourceCode, node.start),
406
+ message: diagnosticMessageFor(match, transition, history),
407
+ rule: RULE_NAME,
408
+ severity: 'error',
409
+ };
410
+ };
411
+
412
+ const provenanceDiagnostics = (
413
+ context: ProjectContext
414
+ ): readonly WardenDiagnostic[] => {
415
+ const historyIssues = context.governedVocabularyHistoryIssues ?? [];
416
+ const invalidTransitionIds = new Set(
417
+ historyIssues.flatMap((issue) =>
418
+ issue.transitionId === undefined ? [] : [issue.transitionId]
419
+ )
420
+ );
421
+ const issues = historyIssues.map((issue) => ({
422
+ filePath: issue.path,
423
+ line: 1,
424
+ message: issue.message,
425
+ rule: RULE_NAME,
426
+ severity: 'error' as const,
427
+ }));
428
+ const missing = listGovernedVocabularyTransitions()
429
+ .filter(
430
+ (transition) =>
431
+ context.governedVocabularyHistoryRequired === true &&
432
+ transition.status === 'complete' &&
433
+ transition.provenance.mode === 'regrade-history' &&
434
+ !invalidTransitionIds.has(transition.id) &&
435
+ !context.governedVocabularyHistoryByTransitionId?.has(transition.id)
436
+ )
437
+ .map((transition) => ({
438
+ filePath: '<governed-vocabulary-history>',
439
+ line: 1,
440
+ message: `Governed transition '${transition.id}' is complete but has no valid committed Regrade history provenance. Run Regrade as the primary migration path before manual review or cleanup.`,
441
+ rule: RULE_NAME,
442
+ severity: 'error' as const,
443
+ }));
444
+ return [...issues, ...missing];
445
+ };
446
+
447
+ const checkSource = (
448
+ sourceCode: string,
449
+ filePath: string,
450
+ projectContext?: ProjectContext
451
+ ): readonly WardenDiagnostic[] => {
452
+ if (isAllowedSourcePath(filePath)) {
453
+ return [];
454
+ }
455
+
456
+ const renames = activeSymbolRenames(projectContext);
457
+ if (renames.length === 0) {
458
+ return [];
459
+ }
460
+
461
+ const parsed = parseWithDiagnostics(filePath, sourceCode);
462
+ if (!parsed.ast || parsed.diagnostics.length > 0) {
463
+ return [];
464
+ }
465
+
466
+ const shorthandIdentifiers = new Set<string>();
467
+ walkWithScopeContext(parsed.ast, (node, astContext) => {
468
+ if (!isShorthandPropertyKeyVisit(node, astContext)) {
469
+ return;
470
+ }
471
+ const name = identifierName(node);
472
+ if (name !== null) {
473
+ shorthandIdentifiers.add(name);
474
+ }
475
+ });
476
+
477
+ const diagnostics: WardenDiagnostic[] = [];
478
+ walkWithScopeContext(parsed.ast, (node, astContext) => {
479
+ if (isDuplicateShorthandValueVisit(node, astContext)) {
480
+ return;
481
+ }
482
+
483
+ const activeMatch = renames
484
+ .map(({ history, rename, targetSegments, transition }) => ({
485
+ history,
486
+ match: resolveSymbolRenameMatch(node, sourceCode, rename),
487
+ targetSegments,
488
+ transition,
489
+ }))
490
+ .find((candidate) => candidate.match !== null);
491
+ if (activeMatch === undefined || activeMatch.match === null) {
492
+ return;
493
+ }
494
+ const { history, match, targetSegments, transition } = activeMatch;
495
+
496
+ diagnostics.push(
497
+ diagnosticFor(
498
+ sourceCode,
499
+ filePath,
500
+ node,
501
+ match,
502
+ reviewReasonFor(
503
+ astContext,
504
+ match,
505
+ shorthandIdentifiers,
506
+ targetSegments
507
+ ),
508
+ transition,
509
+ history
510
+ )
511
+ );
512
+ });
513
+
514
+ return diagnostics;
515
+ };
516
+
517
+ export const governedSymbolResidue: ProjectAwareWardenRule = {
518
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
519
+ return checkSource(sourceCode, filePath);
520
+ },
521
+ checkProject(context: ProjectContext): readonly WardenDiagnostic[] {
522
+ return provenanceDiagnostics(context);
523
+ },
524
+ checkWithContext(
525
+ sourceCode: string,
526
+ filePath: string,
527
+ context: ProjectContext
528
+ ): readonly WardenDiagnostic[] {
529
+ return checkSource(sourceCode, filePath, context);
530
+ },
531
+ description:
532
+ 'Require committed Regrade provenance for governed transitions and detect retired symbols that remain or return.',
533
+ name: RULE_NAME,
534
+ severity: 'error',
535
+ };
@@ -0,0 +1,77 @@
1
+ import type {
2
+ GovernedVocabularyHistoryEvidence,
3
+ GovernedVocabularyHistoryFormJudgment,
4
+ ProjectAwareWardenRule,
5
+ ProjectContext,
6
+ WardenDiagnostic,
7
+ } from './types.js';
8
+
9
+ const RULE_NAME = 'governed-vocabulary-permutation-watch';
10
+
11
+ const formIdentity = (
12
+ form: string,
13
+ evidence: GovernedVocabularyHistoryEvidence
14
+ ): string => (evidence.caseSensitive ? form : form.toLowerCase());
15
+
16
+ const isUnknownPermutation = (
17
+ judgment: GovernedVocabularyHistoryFormJudgment
18
+ ): boolean =>
19
+ judgment.disposition === 'unresolved' &&
20
+ judgment.reason === 'unclassified-neighbor';
21
+
22
+ const compareObservation = (
23
+ left: GovernedVocabularyHistoryFormJudgment,
24
+ right: GovernedVocabularyHistoryFormJudgment
25
+ ): number =>
26
+ (left.representative?.path ?? '').localeCompare(
27
+ right.representative?.path ?? ''
28
+ ) ||
29
+ (left.representative?.line ?? 1) - (right.representative?.line ?? 1) ||
30
+ left.form.localeCompare(right.form);
31
+
32
+ const diagnosticsForHistory = (
33
+ evidence: GovernedVocabularyHistoryEvidence
34
+ ): readonly WardenDiagnostic[] => {
35
+ const unknownByIdentity = new Map<
36
+ string,
37
+ GovernedVocabularyHistoryFormJudgment
38
+ >();
39
+
40
+ for (const judgment of evidence.latestFormJudgments) {
41
+ if (!isUnknownPermutation(judgment)) {
42
+ continue;
43
+ }
44
+ const identity = formIdentity(judgment.form, evidence);
45
+ const current = unknownByIdentity.get(identity);
46
+ if (current === undefined || compareObservation(judgment, current) < 0) {
47
+ unknownByIdentity.set(identity, judgment);
48
+ }
49
+ }
50
+
51
+ return [...unknownByIdentity.entries()]
52
+ .toSorted(([left], [right]) => left.localeCompare(right))
53
+ .map(([, judgment]) => ({
54
+ filePath: judgment.representative?.path ?? evidence.path,
55
+ line: judgment.representative?.line ?? 1,
56
+ message: `Governed transition '${evidence.transitionId}' recorded unknown vocabulary form '${judgment.form}' in committed Regrade history '${evidence.path}' (${evidence.id}). Add the form and run an incremental plan, or classify it as out-of-family or preserved.`,
57
+ rule: RULE_NAME,
58
+ severity: 'warn' as const,
59
+ }));
60
+ };
61
+
62
+ const checkProject = (context: ProjectContext): readonly WardenDiagnostic[] =>
63
+ [...(context.governedVocabularyHistoryByTransitionId?.values() ?? [])]
64
+ .toSorted((left, right) =>
65
+ left.transitionId.localeCompare(right.transitionId)
66
+ )
67
+ .flatMap(diagnosticsForHistory);
68
+
69
+ export const governedVocabularyPermutationWatch: ProjectAwareWardenRule = {
70
+ check: () => [],
71
+ checkProject,
72
+ checkWithContext: () => [],
73
+ description:
74
+ 'Advise when committed Regrade history records unclassified permutations of governed vocabulary.',
75
+ name: RULE_NAME,
76
+ severity: 'warn',
77
+ };