@ontrails/warden 1.0.0-beta.32 → 1.0.0-beta.39
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 +56 -0
- package/README.md +1 -1
- package/package.json +9 -9
- package/src/cli.ts +120 -10
- package/src/command.ts +25 -5
- package/src/drift.ts +116 -7
- package/src/fix.ts +8 -2
- package/src/formatters.ts +3 -8
- package/src/index.ts +30 -2
- package/src/project-context.ts +255 -1
- package/src/rules/ast.ts +4 -0
- package/src/rules/duplicate-exported-symbol.ts +211 -0
- package/src/rules/duplicate-public-contract.ts +2 -1
- package/src/rules/governed-symbol-residue.ts +131 -0
- package/src/rules/index.ts +43 -3
- package/src/rules/metadata.ts +105 -11
- package/src/rules/no-legacy-cli-alias-export.ts +247 -0
- package/src/rules/no-retired-cross-vocabulary.ts +19 -9
- package/src/rules/public-export-example-coverage.ts +5 -0
- package/src/rules/registry-names.ts +12 -2
- package/src/rules/retired-vocabulary.ts +396 -0
- package/src/rules/signal-graph-coaching.ts +32 -3
- package/src/rules/surface-overlay-coherence.ts +262 -0
- package/src/rules/{surface-facet-coherence.ts → surface-trailhead-coherence.ts} +45 -41
- package/src/rules/trailhead-override-divergence.ts +356 -0
- package/src/rules/types.ts +58 -1
- package/src/rules/webhook-route-collision.ts +64 -1
- package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
- package/src/trails/duplicate-public-contract.trail.ts +1 -1
- package/src/trails/governed-symbol-residue.trail.ts +24 -0
- package/src/trails/index.ts +6 -1
- package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
- package/src/trails/schema.ts +39 -0
- package/src/trails/surface-overlay-coherence.trail.ts +24 -0
- package/src/trails/{surface-facet-coherence.trail.ts → surface-trailhead-coherence.trail.ts} +4 -4
- package/src/trails/trailhead-override-divergence.trail.ts +47 -0
- package/src/trails/wrap-rule.ts +10 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coach the app-authored `surfaces` overlay toward coherent bindings.
|
|
3
|
+
*
|
|
4
|
+
* Reads the `surfaces` namespace from the serialized graph overlays and
|
|
5
|
+
* checks, per surface key, that every binding selector matches at least one
|
|
6
|
+
* trail, that grouped bindings do not overlap on expanded members, and that
|
|
7
|
+
* binding names do not shadow real surface entries (single-segment CLI
|
|
8
|
+
* routes, derived MCP tool names).
|
|
9
|
+
*
|
|
10
|
+
* Activation note: standard `trails warden` runs fire this rule. Warden's
|
|
11
|
+
* fresh topo loading collects the app-module overlays export through the
|
|
12
|
+
* shared `resolveTrailsOverlays` channel compile uses (TRL-1209 drift
|
|
13
|
+
* symmetry), so the topo-aware rule context graph carries the same overlays
|
|
14
|
+
* the committed lock embeds. Callers that supply a precomputed graph
|
|
15
|
+
* (committed locks, tests, the rule trail) keep working unchanged.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
SURFACES_OVERLAY_NAMESPACE,
|
|
20
|
+
classifySurfaceBinding,
|
|
21
|
+
deriveMcpToolName,
|
|
22
|
+
matchesTrailPattern,
|
|
23
|
+
surfaceBindingsFromLockOverlays,
|
|
24
|
+
} from '@ontrails/core';
|
|
25
|
+
import type {
|
|
26
|
+
SurfaceBindings,
|
|
27
|
+
SurfaceOverlayBindings,
|
|
28
|
+
Topo,
|
|
29
|
+
} from '@ontrails/core';
|
|
30
|
+
import type { TopoGraph } from '@ontrails/topographer';
|
|
31
|
+
|
|
32
|
+
import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
|
|
33
|
+
|
|
34
|
+
const RULE_NAME = 'surface-overlay-coherence';
|
|
35
|
+
const TOPO_FILE = '<topo>';
|
|
36
|
+
|
|
37
|
+
const SURFACE_KEYS = ['cli', 'http', 'mcp', 'ws'] as const;
|
|
38
|
+
type SurfaceKey = (typeof SURFACE_KEYS)[number];
|
|
39
|
+
|
|
40
|
+
const warn = (message: string): WardenDiagnostic => ({
|
|
41
|
+
filePath: TOPO_FILE,
|
|
42
|
+
line: 1,
|
|
43
|
+
message,
|
|
44
|
+
rule: RULE_NAME,
|
|
45
|
+
severity: 'warn',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const memberRefs = (value: SurfaceBindings[string]): readonly string[] => {
|
|
49
|
+
const shape = classifySurfaceBinding(value);
|
|
50
|
+
return shape.kind === 'synonym' ? [shape.trail] : shape.members;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const expandRefs = (
|
|
54
|
+
refs: readonly string[],
|
|
55
|
+
trailIds: readonly string[]
|
|
56
|
+
): ReadonlySet<string> => {
|
|
57
|
+
const matched = new Set<string>();
|
|
58
|
+
for (const ref of refs) {
|
|
59
|
+
for (const trailId of trailIds) {
|
|
60
|
+
if (matchesTrailPattern(trailId, ref)) {
|
|
61
|
+
matched.add(trailId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return matched;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const zeroMatchDiagnostics = (
|
|
69
|
+
surfaceKey: SurfaceKey,
|
|
70
|
+
bindings: SurfaceBindings,
|
|
71
|
+
trailIds: readonly string[]
|
|
72
|
+
): readonly WardenDiagnostic[] => {
|
|
73
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
74
|
+
for (const [name, value] of Object.entries(bindings)) {
|
|
75
|
+
for (const ref of memberRefs(value)) {
|
|
76
|
+
if (!trailIds.some((trailId) => matchesTrailPattern(trailId, ref))) {
|
|
77
|
+
diagnostics.push(
|
|
78
|
+
warn(
|
|
79
|
+
`Surface overlay binding "${name}" on "${surfaceKey}" references "${ref}", which matches no trails in the topo. Point the binding at an existing trail id or dotted trail-id glob.`
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return diagnostics;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
interface ExpandedGroup {
|
|
89
|
+
readonly name: string;
|
|
90
|
+
readonly trailIds: ReadonlySet<string>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const groupOverlapDiagnostics = (
|
|
94
|
+
surfaceKey: SurfaceKey,
|
|
95
|
+
bindings: SurfaceBindings,
|
|
96
|
+
trailIds: readonly string[]
|
|
97
|
+
): readonly WardenDiagnostic[] => {
|
|
98
|
+
const groups: ExpandedGroup[] = [];
|
|
99
|
+
for (const [name, value] of Object.entries(bindings)) {
|
|
100
|
+
const shape = classifySurfaceBinding(value);
|
|
101
|
+
if (shape.kind === 'group') {
|
|
102
|
+
groups.push({ name, trailIds: expandRefs(shape.members, trailIds) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
107
|
+
for (let i = 0; i < groups.length; i += 1) {
|
|
108
|
+
const first = groups[i];
|
|
109
|
+
if (!first) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
for (let j = i + 1; j < groups.length; j += 1) {
|
|
113
|
+
const second = groups[j];
|
|
114
|
+
if (!second) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const [sharedId] = [...second.trailIds]
|
|
118
|
+
.filter((trailId) => first.trailIds.has(trailId))
|
|
119
|
+
.toSorted();
|
|
120
|
+
if (sharedId !== undefined) {
|
|
121
|
+
diagnostics.push(
|
|
122
|
+
warn(
|
|
123
|
+
`Surface overlay group "${second.name}" on "${surfaceKey}" overlaps group "${first.name}" on trail "${sharedId}". Narrow one group so each trail has one grouped owner per surface.`
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return diagnostics;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const cliCollisionDiagnostics = (
|
|
133
|
+
bindings: SurfaceBindings,
|
|
134
|
+
graph: TopoGraph
|
|
135
|
+
): readonly WardenDiagnostic[] => {
|
|
136
|
+
const routeByName = new Map<
|
|
137
|
+
string,
|
|
138
|
+
{ readonly kind: string; readonly trailId: string }
|
|
139
|
+
>();
|
|
140
|
+
for (const entry of graph.entries) {
|
|
141
|
+
if (entry.kind !== 'trail') {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const path = entry.cli?.path;
|
|
145
|
+
const segment = path?.length === 1 ? path[0] : undefined;
|
|
146
|
+
if (segment !== undefined) {
|
|
147
|
+
routeByName.set(segment, { kind: 'canonical', trailId: entry.id });
|
|
148
|
+
}
|
|
149
|
+
for (const route of entry.cli?.routes ?? []) {
|
|
150
|
+
// Surface-sourced alias routes are the projections of the overlay
|
|
151
|
+
// bindings themselves (TRL-1207), so a binding can never "shadow"
|
|
152
|
+
// one — only canonical paths and trail-owned aliases are real
|
|
153
|
+
// entries a binding name could collide with.
|
|
154
|
+
if (route.source === 'surface') {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const routeSegment = route.path.length === 1 ? route.path[0] : undefined;
|
|
158
|
+
if (routeSegment !== undefined && !routeByName.has(routeSegment)) {
|
|
159
|
+
routeByName.set(routeSegment, {
|
|
160
|
+
kind: route.kind,
|
|
161
|
+
trailId: entry.id,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
168
|
+
for (const name of Object.keys(bindings)) {
|
|
169
|
+
const route = routeByName.get(name);
|
|
170
|
+
if (route !== undefined) {
|
|
171
|
+
diagnostics.push(
|
|
172
|
+
warn(
|
|
173
|
+
`Surface overlay binding "${name}" on "cli" shadows the ${route.kind} CLI route "${name}" for trail "${route.trailId}". Rename the binding so it does not shadow a real entry.`
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return diagnostics;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const mcpCollisionDiagnostics = (
|
|
182
|
+
bindings: SurfaceBindings,
|
|
183
|
+
appName: string,
|
|
184
|
+
graph: TopoGraph
|
|
185
|
+
): readonly WardenDiagnostic[] => {
|
|
186
|
+
const trailIdByToolName = new Map<string, string>();
|
|
187
|
+
for (const entry of graph.entries) {
|
|
188
|
+
if (entry.kind === 'trail') {
|
|
189
|
+
trailIdByToolName.set(deriveMcpToolName(appName, entry.id), entry.id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
194
|
+
for (const name of Object.keys(bindings)) {
|
|
195
|
+
const trailId = trailIdByToolName.get(name);
|
|
196
|
+
if (trailId !== undefined) {
|
|
197
|
+
diagnostics.push(
|
|
198
|
+
warn(
|
|
199
|
+
`Surface overlay binding "${name}" on "mcp" shadows the derived MCP tool name "${name}" for trail "${trailId}". Rename the binding so it does not shadow a real entry.`
|
|
200
|
+
)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return diagnostics;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const collectSurfaceDiagnostics = (
|
|
208
|
+
topo: Topo,
|
|
209
|
+
graph: TopoGraph,
|
|
210
|
+
overlayBindings: SurfaceOverlayBindings
|
|
211
|
+
): readonly WardenDiagnostic[] => {
|
|
212
|
+
const trailIds = [...topo.trails.keys()];
|
|
213
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
214
|
+
for (const surfaceKey of SURFACE_KEYS) {
|
|
215
|
+
const bindings = overlayBindings[surfaceKey];
|
|
216
|
+
if (bindings === undefined) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
diagnostics.push(
|
|
220
|
+
...zeroMatchDiagnostics(surfaceKey, bindings, trailIds),
|
|
221
|
+
...groupOverlapDiagnostics(surfaceKey, bindings, trailIds)
|
|
222
|
+
);
|
|
223
|
+
if (surfaceKey === 'cli') {
|
|
224
|
+
diagnostics.push(...cliCollisionDiagnostics(bindings, graph));
|
|
225
|
+
}
|
|
226
|
+
if (surfaceKey === 'mcp') {
|
|
227
|
+
diagnostics.push(...mcpCollisionDiagnostics(bindings, topo.name, graph));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return diagnostics;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const surfaceOverlayCoherence: TopoAwareWardenRule = {
|
|
234
|
+
checkTopo(topo, context) {
|
|
235
|
+
const graph = context?.graph;
|
|
236
|
+
if (graph?.overlays === undefined) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let overlayBindings: SurfaceOverlayBindings | undefined;
|
|
241
|
+
try {
|
|
242
|
+
overlayBindings = surfaceBindingsFromLockOverlays(graph.overlays);
|
|
243
|
+
} catch (error: unknown) {
|
|
244
|
+
return [
|
|
245
|
+
warn(
|
|
246
|
+
`The "${SURFACES_OVERLAY_NAMESPACE}" overlay in the serialized graph is invalid: ${
|
|
247
|
+
error instanceof Error ? error.message : String(error)
|
|
248
|
+
}`
|
|
249
|
+
),
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
if (overlayBindings === undefined) {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return collectSurfaceDiagnostics(topo, graph, overlayBindings);
|
|
257
|
+
},
|
|
258
|
+
description:
|
|
259
|
+
'Keep app-authored surface overlay bindings pointed at real trails without group overlap or canonical-entry shadowing.',
|
|
260
|
+
name: RULE_NAME,
|
|
261
|
+
severity: 'warn',
|
|
262
|
+
};
|
|
@@ -22,10 +22,10 @@ import {
|
|
|
22
22
|
import type { AstNode } from './ast.js';
|
|
23
23
|
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
24
24
|
|
|
25
|
-
const RULE_NAME = 'surface-
|
|
25
|
+
const RULE_NAME = 'surface-trailhead-coherence';
|
|
26
26
|
|
|
27
|
-
interface
|
|
28
|
-
readonly
|
|
27
|
+
interface TrailheadSelector {
|
|
28
|
+
readonly trailheadId: string;
|
|
29
29
|
readonly node: AstNode;
|
|
30
30
|
readonly value: string;
|
|
31
31
|
}
|
|
@@ -68,28 +68,30 @@ const diagnostic = (
|
|
|
68
68
|
severity: 'warn',
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
const
|
|
71
|
+
const isTrailheadDefinition = (node: AstNode): boolean =>
|
|
72
72
|
node.type === 'ObjectExpression' &&
|
|
73
73
|
findConfigProperty(node, 'trails') !== null;
|
|
74
74
|
|
|
75
|
-
const
|
|
75
|
+
const isTrailheadMapCandidate = (node: AstNode): boolean =>
|
|
76
76
|
objectProperties(node).some((property) => {
|
|
77
77
|
const value = unwrapExpression(propertyValue(property));
|
|
78
|
-
return value !== undefined &&
|
|
78
|
+
return value !== undefined && isTrailheadDefinition(value);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
const
|
|
81
|
+
const isTrailheadMapBindingName = (name: string | null): boolean =>
|
|
82
82
|
name !== null &&
|
|
83
|
-
(name === '
|
|
83
|
+
(name === 'trailheads' ||
|
|
84
|
+
name.endsWith('Trailheads') ||
|
|
85
|
+
name.endsWith('TrailheadMap'));
|
|
84
86
|
|
|
85
|
-
const
|
|
87
|
+
const hasTrailheadMapTypeAnnotation = (
|
|
86
88
|
sourceCode: string,
|
|
87
89
|
node: AstNode
|
|
88
90
|
): boolean => {
|
|
89
91
|
const typeAnnotation = getNodeTypeAnnotation(node);
|
|
90
92
|
return (
|
|
91
93
|
typeAnnotation !== undefined &&
|
|
92
|
-
/\b(?:
|
|
94
|
+
/\b(?:McpSurfaceTrailheadMap|TrailheadMap)\b/.test(
|
|
93
95
|
sourceCode.slice(typeAnnotation.start, typeAnnotation.end)
|
|
94
96
|
)
|
|
95
97
|
);
|
|
@@ -109,10 +111,10 @@ const selectorNodes = (trailsNode: AstNode): readonly AstNode[] | null => {
|
|
|
109
111
|
const collectLiteralSelectors = (
|
|
110
112
|
sourceCode: string,
|
|
111
113
|
filePath: string,
|
|
112
|
-
|
|
114
|
+
trailheadId: string,
|
|
113
115
|
trailsProp: AstNode,
|
|
114
116
|
diagnostics: WardenDiagnostic[]
|
|
115
|
-
): readonly
|
|
117
|
+
): readonly TrailheadSelector[] => {
|
|
116
118
|
const trailsValue = propertyValue(trailsProp);
|
|
117
119
|
const nodes = trailsValue ? selectorNodes(trailsValue) : null;
|
|
118
120
|
if (nodes === null || nodes.length === 0) {
|
|
@@ -121,13 +123,13 @@ const collectLiteralSelectors = (
|
|
|
121
123
|
sourceCode,
|
|
122
124
|
filePath,
|
|
123
125
|
trailsProp,
|
|
124
|
-
`Surface
|
|
126
|
+
`Surface trailhead "${trailheadId}" uses a dynamic trails selector. Keep trailhead selectors as string literals so Warden can check overlap and drift.`
|
|
125
127
|
)
|
|
126
128
|
);
|
|
127
129
|
return [];
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
const selectors:
|
|
132
|
+
const selectors: TrailheadSelector[] = [];
|
|
131
133
|
for (const node of nodes) {
|
|
132
134
|
const selectorValue = extractStringOrTemplateLiteral(node);
|
|
133
135
|
if (selectorValue === null) {
|
|
@@ -136,12 +138,12 @@ const collectLiteralSelectors = (
|
|
|
136
138
|
sourceCode,
|
|
137
139
|
filePath,
|
|
138
140
|
node,
|
|
139
|
-
`Surface
|
|
141
|
+
`Surface trailhead "${trailheadId}" uses a dynamic trails selector. Keep trailhead selectors as string literals so Warden can check overlap and drift.`
|
|
140
142
|
)
|
|
141
143
|
);
|
|
142
144
|
continue;
|
|
143
145
|
}
|
|
144
|
-
selectors.push({
|
|
146
|
+
selectors.push({ node, trailheadId, value: selectorValue });
|
|
145
147
|
}
|
|
146
148
|
return selectors;
|
|
147
149
|
};
|
|
@@ -176,21 +178,21 @@ const literalBooleanProperty = (
|
|
|
176
178
|
};
|
|
177
179
|
|
|
178
180
|
const selectorsMayOverlap = (
|
|
179
|
-
first:
|
|
180
|
-
second:
|
|
181
|
+
first: TrailheadSelector,
|
|
182
|
+
second: TrailheadSelector
|
|
181
183
|
): boolean =>
|
|
182
184
|
first.value === second.value ||
|
|
183
185
|
matchesTrailPattern(first.value, second.value) ||
|
|
184
186
|
matchesTrailPattern(second.value, first.value);
|
|
185
187
|
|
|
186
|
-
const
|
|
188
|
+
const diagnoseTrailheadDefinition = (
|
|
187
189
|
sourceCode: string,
|
|
188
190
|
filePath: string,
|
|
189
|
-
|
|
191
|
+
trailheadId: string,
|
|
190
192
|
definition: AstNode
|
|
191
193
|
): {
|
|
192
194
|
readonly diagnostics: readonly WardenDiagnostic[];
|
|
193
|
-
readonly selectors: readonly
|
|
195
|
+
readonly selectors: readonly TrailheadSelector[];
|
|
194
196
|
} => {
|
|
195
197
|
const diagnostics: WardenDiagnostic[] = [];
|
|
196
198
|
const trailsProp = findConfigProperty(definition, 'trails');
|
|
@@ -200,7 +202,7 @@ const diagnoseFacetDefinition = (
|
|
|
200
202
|
: collectLiteralSelectors(
|
|
201
203
|
sourceCode,
|
|
202
204
|
filePath,
|
|
203
|
-
|
|
205
|
+
trailheadId,
|
|
204
206
|
trailsProp,
|
|
205
207
|
diagnostics
|
|
206
208
|
);
|
|
@@ -211,7 +213,7 @@ const diagnoseFacetDefinition = (
|
|
|
211
213
|
sourceCode,
|
|
212
214
|
filePath,
|
|
213
215
|
definition,
|
|
214
|
-
`Surface
|
|
216
|
+
`Surface trailhead "${trailheadId}" needs a non-empty description so MCP clients and agents can choose it without guessing.`
|
|
215
217
|
)
|
|
216
218
|
);
|
|
217
219
|
}
|
|
@@ -227,7 +229,7 @@ const diagnoseFacetDefinition = (
|
|
|
227
229
|
sourceCode,
|
|
228
230
|
filePath,
|
|
229
231
|
definition,
|
|
230
|
-
`Surface
|
|
232
|
+
`Surface trailhead "${trailheadId}" explicitly sets public visibility without visibilityWideningAccepted: true. Trailheads must not accidentally widen hidden trails.`
|
|
231
233
|
)
|
|
232
234
|
);
|
|
233
235
|
}
|
|
@@ -241,7 +243,7 @@ const diagnoseFacetDefinition = (
|
|
|
241
243
|
sourceCode,
|
|
242
244
|
filePath,
|
|
243
245
|
definition,
|
|
244
|
-
`Surface
|
|
246
|
+
`Surface trailhead "${trailheadId}" accepts visibility widening but does not record descriptionStableThrough review metadata.`
|
|
245
247
|
)
|
|
246
248
|
);
|
|
247
249
|
}
|
|
@@ -249,24 +251,24 @@ const diagnoseFacetDefinition = (
|
|
|
249
251
|
return { diagnostics, selectors };
|
|
250
252
|
};
|
|
251
253
|
|
|
252
|
-
const
|
|
254
|
+
const diagnoseTrailheadMap = (
|
|
253
255
|
sourceCode: string,
|
|
254
256
|
filePath: string,
|
|
255
|
-
|
|
257
|
+
trailheadMap: AstNode
|
|
256
258
|
): readonly WardenDiagnostic[] => {
|
|
257
259
|
const diagnostics: WardenDiagnostic[] = [];
|
|
258
|
-
const selectors:
|
|
260
|
+
const selectors: TrailheadSelector[] = [];
|
|
259
261
|
|
|
260
|
-
for (const property of objectProperties(
|
|
261
|
-
const
|
|
262
|
+
for (const property of objectProperties(trailheadMap)) {
|
|
263
|
+
const trailheadId = getPropertyName(getNodeKey(property));
|
|
262
264
|
const value = unwrapExpression(propertyValue(property));
|
|
263
|
-
if (!
|
|
265
|
+
if (!trailheadId || value === undefined || !isTrailheadDefinition(value)) {
|
|
264
266
|
continue;
|
|
265
267
|
}
|
|
266
|
-
const result =
|
|
268
|
+
const result = diagnoseTrailheadDefinition(
|
|
267
269
|
sourceCode,
|
|
268
270
|
filePath,
|
|
269
|
-
|
|
271
|
+
trailheadId,
|
|
270
272
|
value
|
|
271
273
|
);
|
|
272
274
|
diagnostics.push(...result.diagnostics);
|
|
@@ -282,7 +284,7 @@ const diagnoseFacetMap = (
|
|
|
282
284
|
const second = selectors[j];
|
|
283
285
|
if (
|
|
284
286
|
!second ||
|
|
285
|
-
first.
|
|
287
|
+
first.trailheadId === second.trailheadId ||
|
|
286
288
|
!selectorsMayOverlap(first, second)
|
|
287
289
|
) {
|
|
288
290
|
continue;
|
|
@@ -292,7 +294,7 @@ const diagnoseFacetMap = (
|
|
|
292
294
|
sourceCode,
|
|
293
295
|
filePath,
|
|
294
296
|
second.node,
|
|
295
|
-
`Surface
|
|
297
|
+
`Surface trailhead selector "${second.value}" in "${second.trailheadId}" overlaps selector "${first.value}" in "${first.trailheadId}". Narrow one trailhead so each public trail has one MCP owner.`
|
|
296
298
|
)
|
|
297
299
|
);
|
|
298
300
|
}
|
|
@@ -301,7 +303,7 @@ const diagnoseFacetMap = (
|
|
|
301
303
|
return diagnostics;
|
|
302
304
|
};
|
|
303
305
|
|
|
304
|
-
export const
|
|
306
|
+
export const surfaceTrailheadCoherence: WardenRule = {
|
|
305
307
|
check(sourceCode, filePath) {
|
|
306
308
|
const ast = parse(filePath, sourceCode);
|
|
307
309
|
if (!ast) {
|
|
@@ -316,18 +318,20 @@ export const surfaceFacetCoherence: WardenRule = {
|
|
|
316
318
|
unwrapped === undefined ||
|
|
317
319
|
unwrapped.type !== 'ObjectExpression' ||
|
|
318
320
|
seen.has(unwrapped.start) ||
|
|
319
|
-
!
|
|
321
|
+
!isTrailheadMapCandidate(unwrapped)
|
|
320
322
|
) {
|
|
321
323
|
return;
|
|
322
324
|
}
|
|
323
325
|
seen.add(unwrapped.start);
|
|
324
|
-
diagnostics.push(
|
|
326
|
+
diagnostics.push(
|
|
327
|
+
...diagnoseTrailheadMap(sourceCode, filePath, unwrapped)
|
|
328
|
+
);
|
|
325
329
|
};
|
|
326
330
|
|
|
327
331
|
walk(ast, (node) => {
|
|
328
332
|
if (node.type === 'Property') {
|
|
329
333
|
const propertyName = getPropertyName(getNodeKey(node));
|
|
330
|
-
if (propertyName === '
|
|
334
|
+
if (propertyName === 'trailheads') {
|
|
331
335
|
diagnoseCandidate(propertyValue(node));
|
|
332
336
|
}
|
|
333
337
|
return;
|
|
@@ -337,7 +341,7 @@ export const surfaceFacetCoherence: WardenRule = {
|
|
|
337
341
|
const bindingName = getNodeName(getNodeId(node));
|
|
338
342
|
if (
|
|
339
343
|
typeof bindingName === 'string' &&
|
|
340
|
-
|
|
344
|
+
isTrailheadMapBindingName(bindingName)
|
|
341
345
|
) {
|
|
342
346
|
diagnoseCandidate(getNodeInit(node) ?? undefined);
|
|
343
347
|
}
|
|
@@ -347,7 +351,7 @@ export const surfaceFacetCoherence: WardenRule = {
|
|
|
347
351
|
if (
|
|
348
352
|
(node.type === 'TSAsExpression' ||
|
|
349
353
|
node.type === 'TSSatisfiesExpression') &&
|
|
350
|
-
|
|
354
|
+
hasTrailheadMapTypeAnnotation(sourceCode, node)
|
|
351
355
|
) {
|
|
352
356
|
diagnoseCandidate(node);
|
|
353
357
|
}
|