@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.
- package/CHANGELOG.md +1556 -0
- package/README.md +200 -0
- package/bin/warden.ts +51 -0
- package/package.json +51 -0
- package/src/adapter-check.ts +136 -0
- package/src/cli.ts +2159 -0
- package/src/command.ts +1167 -0
- package/src/config.ts +193 -0
- package/src/draft.ts +22 -0
- package/src/drift.ts +260 -0
- package/src/fix.ts +126 -0
- package/src/formatters.ts +178 -0
- package/src/guide.ts +245 -0
- package/src/index.ts +295 -0
- package/src/project-context.ts +446 -0
- package/src/project-rules.ts +290 -0
- package/src/regrade-history.ts +188 -0
- package/src/regrade-receipt.ts +484 -0
- package/src/resolve.ts +531 -0
- package/src/rules/activation-orphan.ts +97 -0
- package/src/rules/captured-kernel.ts +375 -0
- package/src/rules/circular-refs.ts +150 -0
- package/src/rules/cli-command-route-coherence.ts +177 -0
- package/src/rules/composes-declarations.ts +839 -0
- package/src/rules/context-no-surface-types.ts +214 -0
- package/src/rules/dead-internal-trail.ts +161 -0
- package/src/rules/dead-public-trail.ts +258 -0
- package/src/rules/draft-file-marking.ts +155 -0
- package/src/rules/draft-visible-debt.ts +83 -0
- package/src/rules/duplicate-exported-symbol.ts +172 -0
- package/src/rules/duplicate-public-contract.ts +137 -0
- package/src/rules/entity-exists.ts +254 -0
- package/src/rules/entity-ids.ts +15 -0
- package/src/rules/error-mapping-completeness.ts +290 -0
- package/src/rules/example-valid.ts +395 -0
- package/src/rules/fires-declarations.ts +740 -0
- package/src/rules/governed-symbol-residue.ts +535 -0
- package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
- package/src/rules/implementation-returns-result.ts +1733 -0
- package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
- package/src/rules/incomplete-crud.ts +583 -0
- package/src/rules/index.ts +328 -0
- package/src/rules/intent-propagation.ts +125 -0
- package/src/rules/layer-field-name-drift.ts +102 -0
- package/src/rules/library-render-coherence.ts +97 -0
- package/src/rules/metadata.ts +929 -0
- package/src/rules/missing-reconcile.ts +97 -0
- package/src/rules/missing-visibility.ts +111 -0
- package/src/rules/no-destructured-compose.ts +196 -0
- package/src/rules/no-dev-permit-in-source.ts +99 -0
- package/src/rules/no-direct-implementation-call.ts +52 -0
- package/src/rules/no-legacy-cli-alias-export.ts +247 -0
- package/src/rules/no-legacy-layer-imports.ts +211 -0
- package/src/rules/no-native-error-result.ts +118 -0
- package/src/rules/no-redundant-result-error-wrap.ts +382 -0
- package/src/rules/no-retired-cross-vocabulary.ts +203 -0
- package/src/rules/no-sync-result-assumption.ts +1199 -0
- package/src/rules/no-throw-in-detour-recover.ts +225 -0
- package/src/rules/no-throw-in-implementation.ts +48 -0
- package/src/rules/no-top-level-surface.ts +371 -0
- package/src/rules/on-references-exist.ts +194 -0
- package/src/rules/orphaned-signal.ts +149 -0
- package/src/rules/owner-render-parity.ts +143 -0
- package/src/rules/permit-governance.ts +25 -0
- package/src/rules/prefer-schema-inference.ts +141 -0
- package/src/rules/public-export-example-coverage.ts +561 -0
- package/src/rules/public-internal-deep-imports.ts +454 -0
- package/src/rules/public-output-schema.ts +29 -0
- package/src/rules/public-union-output-discriminants.ts +150 -0
- package/src/rules/read-intent-fires.ts +188 -0
- package/src/rules/reference-exists.ts +97 -0
- package/src/rules/registry-names.ts +171 -0
- package/src/rules/resolved-import-boundary.ts +146 -0
- package/src/rules/resource-declarations.ts +697 -0
- package/src/rules/resource-exists.ts +181 -0
- package/src/rules/resource-id-grammar.ts +65 -0
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/retired-vocabulary.ts +1407 -0
- package/src/rules/scan.ts +59 -0
- package/src/rules/scheduled-destroy-intent.ts +44 -0
- package/src/rules/signal-graph-coaching.ts +220 -0
- package/src/rules/source/composition.ts +165 -0
- package/src/rules/source/drafts.ts +164 -0
- package/src/rules/source/entities.ts +618 -0
- package/src/rules/source/pragmas.ts +45 -0
- package/src/rules/source/resources.ts +64 -0
- package/src/rules/source/signals.ts +397 -0
- package/src/rules/source/stores.ts +310 -0
- package/src/rules/specs.ts +388 -0
- package/src/rules/static-resource-accessor-preference.ts +654 -0
- package/src/rules/structure.ts +234 -0
- package/src/rules/surface-overlay-coherence.ts +262 -0
- package/src/rules/surface-trailhead-coherence.ts +366 -0
- package/src/rules/trail-fork-coaching.ts +625 -0
- package/src/rules/trail-versioning-source.ts +1076 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/trailhead-override-divergence.ts +356 -0
- package/src/rules/types.ts +464 -0
- package/src/rules/unmaterialized-activation-source.ts +85 -0
- package/src/rules/unreachable-detour-shadowing.ts +343 -0
- package/src/rules/valid-describe-refs.ts +224 -0
- package/src/rules/valid-detour-contract.ts +78 -0
- package/src/rules/warden-export-symmetry.ts +540 -0
- package/src/rules/warden-rules-use-ast.ts +1109 -0
- package/src/rules/webhook-route-collision.ts +306 -0
- package/src/rules/workspace-lock-ownership.ts +34 -0
- package/src/trails/activation-orphan.trail.ts +84 -0
- package/src/trails/captured-kernel.trail.ts +108 -0
- package/src/trails/circular-refs.trail.ts +29 -0
- package/src/trails/cli-command-route-coherence.trail.ts +47 -0
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/dead-internal-trail.trail.ts +26 -0
- package/src/trails/dead-public-trail.trail.ts +31 -0
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/draft-file-marking.trail.ts +16 -0
- package/src/trails/draft-visible-debt.trail.ts +16 -0
- package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
- package/src/trails/duplicate-public-contract.trail.ts +47 -0
- package/src/trails/entity-exists.trail.ts +21 -0
- package/src/trails/error-mapping-completeness.trail.ts +30 -0
- package/src/trails/example-valid.trail.ts +25 -0
- package/src/trails/fires-declarations.trail.ts +23 -0
- package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
- package/src/trails/governed-symbol-residue.trail.ts +24 -0
- package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
- package/src/trails/implementation-returns-result.trail.ts +20 -0
- package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
- package/src/trails/incomplete-crud.trail.ts +39 -0
- package/src/trails/index.ts +91 -0
- package/src/trails/intent-propagation.trail.ts +30 -0
- package/src/trails/layer-field-name-drift.trail.ts +39 -0
- package/src/trails/library-render-coherence.trail.ts +43 -0
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-reconcile.trail.ts +33 -0
- package/src/trails/missing-visibility.trail.ts +22 -0
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
- package/src/trails/no-direct-implementation-call.trail.ts +16 -0
- package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
- package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
- package/src/trails/no-native-error-result.trail.ts +18 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
- package/src/trails/no-sync-result-assumption.trail.ts +19 -0
- package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
- package/src/trails/no-throw-in-implementation.trail.ts +20 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/on-references-exist.trail.ts +21 -0
- package/src/trails/orphaned-signal.trail.ts +36 -0
- package/src/trails/owner-render-parity.trail.ts +26 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/permit-governance.trail.ts +51 -0
- package/src/trails/prefer-schema-inference.trail.ts +21 -0
- package/src/trails/public-export-example-coverage.trail.ts +16 -0
- package/src/trails/public-internal-deep-imports.trail.ts +94 -0
- package/src/trails/public-output-schema.trail.ts +55 -0
- package/src/trails/public-union-output-discriminants.trail.ts +33 -0
- package/src/trails/read-intent-fires.trail.ts +20 -0
- package/src/trails/reference-exists.trail.ts +25 -0
- package/src/trails/resolved-import-boundary.trail.ts +109 -0
- package/src/trails/resource-declarations.trail.ts +25 -0
- package/src/trails/resource-exists.trail.ts +27 -0
- package/src/trails/resource-id-grammar.trail.ts +39 -0
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +205 -0
- package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
- package/src/trails/schema.ts +295 -0
- package/src/trails/signal-graph-coaching.trail.ts +77 -0
- package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
- package/src/trails/surface-overlay-coherence.trail.ts +24 -0
- package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
- package/src/trails/topo.ts +6 -0
- package/src/trails/trail-fork-coaching.trail.ts +42 -0
- package/src/trails/trailhead-override-divergence.trail.ts +47 -0
- package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
- package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
- package/src/trails/valid-describe-refs.trail.ts +18 -0
- package/src/trails/valid-detour-contract.trail.ts +71 -0
- package/src/trails/version-gap.trail.ts +35 -0
- package/src/trails/version-pinned-compose.trail.ts +23 -0
- package/src/trails/version-without-examples.trail.ts +38 -0
- package/src/trails/warden-export-symmetry.trail.ts +16 -0
- package/src/trails/warden-rules-use-ast.trail.ts +64 -0
- package/src/trails/webhook-route-collision.trail.ts +50 -0
- package/src/trails/workspace-lock-ownership.trail.ts +16 -0
- package/src/trails/wrap-rule.ts +269 -0
- 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
|
+
};
|