@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,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warn when a call-site MCP trailhead map diverges from the app-authored
|
|
3
|
+
* `surfaces` overlay's `mcp` list bindings.
|
|
4
|
+
*
|
|
5
|
+
* The overlay is the authored, lockable default; a call-site trailhead map
|
|
6
|
+
* is a supported override-in-context that wins at runtime. Divergence between
|
|
7
|
+
* the two is legal but must be visible: an agent reading the committed lock
|
|
8
|
+
* would otherwise trust grouped entries the running surface does not project.
|
|
9
|
+
*
|
|
10
|
+
* Rule-kind note: no Warden rule kind currently sees both the serialized
|
|
11
|
+
* graph overlays and source ASTs directly. This rule is the closest honest
|
|
12
|
+
* shape — a project-aware source rule whose `ProjectContext` carries
|
|
13
|
+
* per-app authored `mcp` binding sets resolved from the run's topo targets
|
|
14
|
+
* (graph overlays when available, else app-module overlay registrations).
|
|
15
|
+
* A call-site map is attributed to an app only when one of its literal
|
|
16
|
+
* member selectors matches a trail id in that app's topo, so one app's
|
|
17
|
+
* authored bindings never flag another app's call-site map. Without
|
|
18
|
+
* context (`check` without context, or a run with no topo targets) the
|
|
19
|
+
* rule stays silent rather than guessing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { classifySurfaceBinding, matchesTrailPattern } from '@ontrails/core';
|
|
23
|
+
import type { SurfaceBindings } from '@ontrails/core';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
extractStringOrTemplateLiteral,
|
|
27
|
+
findConfigProperty,
|
|
28
|
+
getNodeArgument,
|
|
29
|
+
getNodeElements,
|
|
30
|
+
getNodeExpression,
|
|
31
|
+
getNodeId,
|
|
32
|
+
getNodeInit,
|
|
33
|
+
getNodeKey,
|
|
34
|
+
getNodeName,
|
|
35
|
+
getNodeProperties,
|
|
36
|
+
getNodeTypeAnnotation,
|
|
37
|
+
getNodeValueNode,
|
|
38
|
+
getPropertyName,
|
|
39
|
+
offsetToLine,
|
|
40
|
+
parse,
|
|
41
|
+
walk,
|
|
42
|
+
} from './ast.js';
|
|
43
|
+
import type { AstNode } from './ast.js';
|
|
44
|
+
import type {
|
|
45
|
+
AuthoredMcpSurfaceBindingSet,
|
|
46
|
+
ProjectAwareWardenRule,
|
|
47
|
+
ProjectContext,
|
|
48
|
+
WardenDiagnostic,
|
|
49
|
+
} from './types.js';
|
|
50
|
+
|
|
51
|
+
const RULE_NAME = 'trailhead-override-divergence';
|
|
52
|
+
|
|
53
|
+
const unwrapExpression = (node: AstNode | undefined): AstNode | undefined => {
|
|
54
|
+
let current = node;
|
|
55
|
+
while (
|
|
56
|
+
current?.type === 'TSAsExpression' ||
|
|
57
|
+
current?.type === 'TSSatisfiesExpression'
|
|
58
|
+
) {
|
|
59
|
+
current = getNodeExpression(current) ?? getNodeArgument(current);
|
|
60
|
+
}
|
|
61
|
+
return current;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const objectProperties = (node: AstNode): readonly AstNode[] =>
|
|
65
|
+
node.type === 'ObjectExpression' ? (getNodeProperties(node) ?? []) : [];
|
|
66
|
+
|
|
67
|
+
const propertyValue = (property: AstNode): AstNode | undefined =>
|
|
68
|
+
property.type === 'Property' ? getNodeValueNode(property) : undefined;
|
|
69
|
+
|
|
70
|
+
const isTrailheadDefinition = (node: AstNode): boolean =>
|
|
71
|
+
node.type === 'ObjectExpression' &&
|
|
72
|
+
findConfigProperty(node, 'trails') !== null;
|
|
73
|
+
|
|
74
|
+
const isTrailheadMapCandidate = (node: AstNode): boolean =>
|
|
75
|
+
objectProperties(node).some((property) => {
|
|
76
|
+
const value = unwrapExpression(propertyValue(property));
|
|
77
|
+
return value !== undefined && isTrailheadDefinition(value);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const isTrailheadMapBindingName = (name: string | null): boolean =>
|
|
81
|
+
name !== null &&
|
|
82
|
+
(name === 'trailheads' ||
|
|
83
|
+
name.endsWith('Trailheads') ||
|
|
84
|
+
name.endsWith('TrailheadMap'));
|
|
85
|
+
|
|
86
|
+
const hasTrailheadMapTypeAnnotation = (
|
|
87
|
+
sourceCode: string,
|
|
88
|
+
node: AstNode
|
|
89
|
+
): boolean => {
|
|
90
|
+
const typeAnnotation = getNodeTypeAnnotation(node);
|
|
91
|
+
return (
|
|
92
|
+
typeAnnotation !== undefined &&
|
|
93
|
+
/\b(?:McpSurfaceTrailheadMap|TrailheadMap)\b/.test(
|
|
94
|
+
sourceCode.slice(typeAnnotation.start, typeAnnotation.end)
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const diagnostic = (
|
|
100
|
+
sourceCode: string,
|
|
101
|
+
filePath: string,
|
|
102
|
+
node: AstNode,
|
|
103
|
+
message: string
|
|
104
|
+
): WardenDiagnostic => ({
|
|
105
|
+
filePath,
|
|
106
|
+
line: offsetToLine(sourceCode, node.start),
|
|
107
|
+
message,
|
|
108
|
+
rule: RULE_NAME,
|
|
109
|
+
severity: 'warn',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Collect the literal selector strings of one trailhead definition. Returns
|
|
114
|
+
* `null` when any selector is dynamic — `surface-trailhead-coherence`
|
|
115
|
+
* already flags those, so this rule skips the comparison instead of
|
|
116
|
+
* double-reporting.
|
|
117
|
+
*/
|
|
118
|
+
const literalSelectors = (definition: AstNode): readonly string[] | null => {
|
|
119
|
+
const trailsProp = findConfigProperty(definition, 'trails');
|
|
120
|
+
const trailsValue = trailsProp ? propertyValue(trailsProp) : undefined;
|
|
121
|
+
const value = unwrapExpression(trailsValue);
|
|
122
|
+
if (!value) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const nodes =
|
|
126
|
+
value.type === 'ArrayExpression'
|
|
127
|
+
? getNodeElements(value).filter((element) => element !== null)
|
|
128
|
+
: [value];
|
|
129
|
+
const selectors: string[] = [];
|
|
130
|
+
for (const node of nodes) {
|
|
131
|
+
const selector = extractStringOrTemplateLiteral(node);
|
|
132
|
+
if (selector === null) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
selectors.push(selector);
|
|
136
|
+
}
|
|
137
|
+
return selectors;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const authoredGroupSelectors = (
|
|
141
|
+
bindings: SurfaceBindings
|
|
142
|
+
): ReadonlyMap<string, readonly string[]> => {
|
|
143
|
+
const groups = new Map<string, readonly string[]>();
|
|
144
|
+
for (const [name, value] of Object.entries(bindings)) {
|
|
145
|
+
const shape = classifySurfaceBinding(value);
|
|
146
|
+
if (shape.kind === 'group') {
|
|
147
|
+
groups.set(name, shape.members);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return groups;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const sameSelectorSet = (
|
|
154
|
+
first: readonly string[],
|
|
155
|
+
second: readonly string[]
|
|
156
|
+
): boolean => {
|
|
157
|
+
const firstSet = new Set(first);
|
|
158
|
+
const secondSet = new Set(second);
|
|
159
|
+
return (
|
|
160
|
+
firstSet.size === secondSet.size &&
|
|
161
|
+
[...firstSet].every((selector) => secondSet.has(selector))
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const formatSelectors = (selectors: readonly string[]): string =>
|
|
166
|
+
[...new Set(selectors)]
|
|
167
|
+
.toSorted()
|
|
168
|
+
.map((selector) => `"${selector}"`)
|
|
169
|
+
.join(', ');
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Attribute a call-site trailhead map to a binding set: the map belongs to
|
|
173
|
+
* an app when at least one of its literal member selectors matches a trail
|
|
174
|
+
* id registered in that app's topo.
|
|
175
|
+
*/
|
|
176
|
+
const mapBelongsToSet = (
|
|
177
|
+
trailheadMap: AstNode,
|
|
178
|
+
set: AuthoredMcpSurfaceBindingSet
|
|
179
|
+
): boolean =>
|
|
180
|
+
objectProperties(trailheadMap).some((property) => {
|
|
181
|
+
const value = unwrapExpression(propertyValue(property));
|
|
182
|
+
if (value === undefined || !isTrailheadDefinition(value)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
const selectors = literalSelectors(value);
|
|
186
|
+
return (
|
|
187
|
+
selectors !== null &&
|
|
188
|
+
selectors.some((selector) =>
|
|
189
|
+
set.trailIds.some((trailId) => matchesTrailPattern(trailId, selector))
|
|
190
|
+
)
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const diagnoseTrailheadMap = (
|
|
195
|
+
sourceCode: string,
|
|
196
|
+
filePath: string,
|
|
197
|
+
trailheadMap: AstNode,
|
|
198
|
+
set: AuthoredMcpSurfaceBindingSet,
|
|
199
|
+
groups: ReadonlyMap<string, readonly string[]>
|
|
200
|
+
): readonly WardenDiagnostic[] => {
|
|
201
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
202
|
+
const groupNames = [...groups.keys()].toSorted();
|
|
203
|
+
|
|
204
|
+
for (const property of objectProperties(trailheadMap)) {
|
|
205
|
+
const trailheadId = getPropertyName(getNodeKey(property));
|
|
206
|
+
const value = unwrapExpression(propertyValue(property));
|
|
207
|
+
if (!trailheadId || value === undefined || !isTrailheadDefinition(value)) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const authored = groups.get(trailheadId);
|
|
212
|
+
if (authored === undefined) {
|
|
213
|
+
diagnostics.push(
|
|
214
|
+
diagnostic(
|
|
215
|
+
sourceCode,
|
|
216
|
+
filePath,
|
|
217
|
+
property,
|
|
218
|
+
`Call-site MCP trailhead "${trailheadId}" has no matching mcp list binding in app "${set.appName}"'s surfaceOverlay (authored groups: ${groupNames.length > 0 ? groupNames.map((name) => `"${name}"`).join(', ') : 'none'}). The call-site map overrides the lockable default at runtime — author the same binding in surfaceOverlay({ mcp }) or rename one side.`
|
|
219
|
+
)
|
|
220
|
+
);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const selectors = literalSelectors(value);
|
|
225
|
+
if (selectors === null) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!sameSelectorSet(selectors, authored)) {
|
|
229
|
+
diagnostics.push(
|
|
230
|
+
diagnostic(
|
|
231
|
+
sourceCode,
|
|
232
|
+
filePath,
|
|
233
|
+
property,
|
|
234
|
+
`Call-site MCP trailhead "${trailheadId}" selects [${formatSelectors(selectors)}] but app "${set.appName}"'s surfaceOverlay mcp binding "${trailheadId}" authors [${formatSelectors(authored)}]. The call-site map overrides the lockable default at runtime — align the member selectors or make the intentional divergence explicit by renaming one side.`
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The override replaces the authored default whole-map at runtime, so an
|
|
241
|
+
// authored group missing from the call-site map is silently dropped while
|
|
242
|
+
// the committed lock still advertises it.
|
|
243
|
+
const callSiteNames = new Set(
|
|
244
|
+
objectProperties(trailheadMap).flatMap((property) => {
|
|
245
|
+
const name = getPropertyName(getNodeKey(property));
|
|
246
|
+
const value = unwrapExpression(propertyValue(property));
|
|
247
|
+
return name && value !== undefined && isTrailheadDefinition(value)
|
|
248
|
+
? [name]
|
|
249
|
+
: [];
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
for (const name of groupNames) {
|
|
253
|
+
if (!callSiteNames.has(name)) {
|
|
254
|
+
diagnostics.push(
|
|
255
|
+
diagnostic(
|
|
256
|
+
sourceCode,
|
|
257
|
+
filePath,
|
|
258
|
+
trailheadMap,
|
|
259
|
+
`App "${set.appName}"'s surfaceOverlay mcp binding "${name}" is not carried by this call-site trailhead map, so it will not be projected at runtime while the committed lock still advertises it. Add "${name}" to the call-site map or remove it from the authored overlay.`
|
|
260
|
+
)
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return diagnostics;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Warn when a source file's call-site MCP trailhead map diverges from the
|
|
270
|
+
* app-authored `surfaces` overlay's `mcp` list bindings.
|
|
271
|
+
*/
|
|
272
|
+
export const trailheadOverrideDivergence: ProjectAwareWardenRule = {
|
|
273
|
+
check(): readonly WardenDiagnostic[] {
|
|
274
|
+
// Without project context there are no authored bindings to compare
|
|
275
|
+
// against; stay silent instead of guessing.
|
|
276
|
+
return [];
|
|
277
|
+
},
|
|
278
|
+
checkWithContext(
|
|
279
|
+
sourceCode: string,
|
|
280
|
+
filePath: string,
|
|
281
|
+
context: ProjectContext
|
|
282
|
+
): readonly WardenDiagnostic[] {
|
|
283
|
+
const sets = (context.authoredMcpSurfaceBindingSets ?? [])
|
|
284
|
+
.map((set) => ({
|
|
285
|
+
groups: authoredGroupSelectors(set.bindings),
|
|
286
|
+
set,
|
|
287
|
+
}))
|
|
288
|
+
.filter(({ groups }) => groups.size > 0);
|
|
289
|
+
if (sets.length === 0) {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const ast = parse(filePath, sourceCode);
|
|
294
|
+
if (!ast) {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const seen = new Set<number>();
|
|
299
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
300
|
+
const diagnoseCandidate = (node: AstNode | undefined): void => {
|
|
301
|
+
const unwrapped = unwrapExpression(node);
|
|
302
|
+
if (
|
|
303
|
+
unwrapped === undefined ||
|
|
304
|
+
unwrapped.type !== 'ObjectExpression' ||
|
|
305
|
+
seen.has(unwrapped.start) ||
|
|
306
|
+
!isTrailheadMapCandidate(unwrapped)
|
|
307
|
+
) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
seen.add(unwrapped.start);
|
|
311
|
+
for (const { set, groups } of sets) {
|
|
312
|
+
if (!mapBelongsToSet(unwrapped, set)) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
diagnostics.push(
|
|
316
|
+
...diagnoseTrailheadMap(sourceCode, filePath, unwrapped, set, groups)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
walk(ast, (node) => {
|
|
322
|
+
if (node.type === 'Property') {
|
|
323
|
+
const propertyName = getPropertyName(getNodeKey(node));
|
|
324
|
+
if (propertyName === 'trailheads') {
|
|
325
|
+
diagnoseCandidate(propertyValue(node));
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (node.type === 'VariableDeclarator') {
|
|
331
|
+
const bindingName = getNodeName(getNodeId(node));
|
|
332
|
+
if (
|
|
333
|
+
typeof bindingName === 'string' &&
|
|
334
|
+
isTrailheadMapBindingName(bindingName)
|
|
335
|
+
) {
|
|
336
|
+
diagnoseCandidate(getNodeInit(node) ?? undefined);
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (
|
|
342
|
+
(node.type === 'TSAsExpression' ||
|
|
343
|
+
node.type === 'TSSatisfiesExpression') &&
|
|
344
|
+
hasTrailheadMapTypeAnnotation(sourceCode, node)
|
|
345
|
+
) {
|
|
346
|
+
diagnoseCandidate(node);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return diagnostics;
|
|
351
|
+
},
|
|
352
|
+
description:
|
|
353
|
+
'Call-site MCP trailhead maps stay aligned with the app-authored surfaces overlay mcp bindings they override.',
|
|
354
|
+
name: RULE_NAME,
|
|
355
|
+
severity: 'warn',
|
|
356
|
+
};
|
package/src/rules/types.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
Intent,
|
|
4
4
|
RuleDiagnosticBase,
|
|
5
5
|
ScanTargets,
|
|
6
|
+
SurfaceBindings,
|
|
6
7
|
Topo,
|
|
7
8
|
} from '@ontrails/core';
|
|
8
9
|
import type { TopoGraph } from '@ontrails/topographer';
|
|
@@ -104,8 +105,13 @@ export interface WardenGuidance {
|
|
|
104
105
|
* Names the kind of mechanical change so agents, the guide, and downstream
|
|
105
106
|
* regrades can route by class. `term-rewrite` is the durable name for retired
|
|
106
107
|
* vocabulary renames (`vocab-cutover` is historical wording only).
|
|
108
|
+
* `export-restructure` names structural export rewrites — the finding is not
|
|
109
|
+
* a rename but a reshaping of a module export (for example wrapping a legacy
|
|
110
|
+
* CLI alias map into a `surfaceOverlay({ cli })` entry inside
|
|
111
|
+
* `trailsOverlays`), so downstream Regrade routes it to a structural
|
|
112
|
+
* transform class instead of a span replacement.
|
|
107
113
|
*/
|
|
108
|
-
export type WardenFixClass = 'term-rewrite';
|
|
114
|
+
export type WardenFixClass = 'export-restructure' | 'term-rewrite';
|
|
109
115
|
|
|
110
116
|
/**
|
|
111
117
|
* How safe a fix is to apply without human review.
|
|
@@ -235,6 +241,29 @@ export interface WardenDiagnostic extends RuleDiagnosticBase {
|
|
|
235
241
|
readonly fix?: WardenFix | undefined;
|
|
236
242
|
}
|
|
237
243
|
|
|
244
|
+
/** Public exported symbol observed from a first-party package export target. */
|
|
245
|
+
export interface WardenExportedSymbolDefinition {
|
|
246
|
+
/** Exported binding name. */
|
|
247
|
+
readonly name: string;
|
|
248
|
+
/** Export declaration kind that introduced the binding. */
|
|
249
|
+
readonly kind:
|
|
250
|
+
| 'class'
|
|
251
|
+
| 'const'
|
|
252
|
+
| 'enum'
|
|
253
|
+
| 'export'
|
|
254
|
+
| 'function'
|
|
255
|
+
| 'interface'
|
|
256
|
+
| 'type';
|
|
257
|
+
/** Absolute or runner-provided file path containing the definition. */
|
|
258
|
+
readonly filePath: string;
|
|
259
|
+
/** 1-based source line where the exported definition starts. */
|
|
260
|
+
readonly line: number;
|
|
261
|
+
/** First-party workspace package that owns the definition. */
|
|
262
|
+
readonly workspaceName: string;
|
|
263
|
+
/** Filesystem root of the owning workspace. */
|
|
264
|
+
readonly workspaceRoot: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
238
267
|
/**
|
|
239
268
|
* A warden rule analyzes one source file and returns diagnostics.
|
|
240
269
|
*
|
|
@@ -258,10 +287,33 @@ export interface WardenRule {
|
|
|
258
287
|
) => readonly WardenDiagnostic[];
|
|
259
288
|
}
|
|
260
289
|
|
|
290
|
+
/**
|
|
291
|
+
* One app's authored `surfaces` overlay `mcp` bindings plus the trail ids
|
|
292
|
+
* that scope which source files the bindings can be compared against.
|
|
293
|
+
*/
|
|
294
|
+
export interface AuthoredMcpSurfaceBindingSet {
|
|
295
|
+
/** Stable app/topo label the bindings were authored for. */
|
|
296
|
+
readonly appName: string;
|
|
297
|
+
/** The `mcp` bindings from the app's `surfaces` overlay. */
|
|
298
|
+
readonly bindings: SurfaceBindings;
|
|
299
|
+
/** Trail ids registered in the owning app's topo. */
|
|
300
|
+
readonly trailIds: readonly string[];
|
|
301
|
+
}
|
|
302
|
+
|
|
261
303
|
/**
|
|
262
304
|
* Options for compose-file rules that need knowledge of all trail IDs in a project.
|
|
263
305
|
*/
|
|
264
306
|
export interface ProjectContext {
|
|
307
|
+
/**
|
|
308
|
+
* Per-app authored `mcp` surface bindings, resolved from the project's
|
|
309
|
+
* topo targets (the serialized graph overlays when available, else the
|
|
310
|
+
* app-module overlay registrations). Each set carries the owning app's
|
|
311
|
+
* trail ids so source rules can attribute a call-site surface option to
|
|
312
|
+
* the app whose overlay authored the default before comparing.
|
|
313
|
+
*/
|
|
314
|
+
readonly authoredMcpSurfaceBindingSets?:
|
|
315
|
+
| readonly AuthoredMcpSurfaceBindingSet[]
|
|
316
|
+
| undefined;
|
|
265
317
|
/** All known contour names in the project. */
|
|
266
318
|
readonly knownContourIds?: ReadonlySet<string>;
|
|
267
319
|
/** Store table IDs used with the CRUD factory across the project. */
|
|
@@ -294,6 +346,11 @@ export interface ProjectContext {
|
|
|
294
346
|
>;
|
|
295
347
|
/** Non-private published @ontrails workspaces discovered from the root manifest. */
|
|
296
348
|
readonly publicWorkspaces?: ReadonlyMap<string, WardenPublicWorkspace>;
|
|
349
|
+
/** Public package export definitions grouped by exported name. */
|
|
350
|
+
readonly exportedSymbolDefinitionsByName?: ReadonlyMap<
|
|
351
|
+
string,
|
|
352
|
+
readonly WardenExportedSymbolDefinition[]
|
|
353
|
+
>;
|
|
297
354
|
/** Normalized trail intents by trail ID across the project. */
|
|
298
355
|
readonly trailIntentsById?: ReadonlyMap<string, Intent>;
|
|
299
356
|
/**
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
shouldIncludeTrailForSurface,
|
|
3
|
+
webhookPathPatternsOverlap,
|
|
4
|
+
} from '@ontrails/core';
|
|
2
5
|
import type { Topo, Trail } from '@ontrails/core';
|
|
3
6
|
|
|
4
7
|
import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
|
|
@@ -200,6 +203,65 @@ const collectPolicyMismatchDiagnostics = (
|
|
|
200
203
|
return diagnostics;
|
|
201
204
|
};
|
|
202
205
|
|
|
206
|
+
const hasDynamicSegment = (path: string): boolean => path.includes('/:');
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Flag distinct route keys whose path patterns can both match one concrete
|
|
210
|
+
* request. Exact-key collisions are handled by the grouping pass; this pass
|
|
211
|
+
* extends detection to dynamic-segment patterns (`/hooks/:endpoint` vs
|
|
212
|
+
* `/hooks/github`), where at least one webhook claim participates.
|
|
213
|
+
*/
|
|
214
|
+
const collectPatternOverlapDiagnostics = (
|
|
215
|
+
claimsByRoute: ReadonlyMap<string, readonly RouteClaim[]>
|
|
216
|
+
): WardenDiagnostic[] => {
|
|
217
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
218
|
+
const keys = [...claimsByRoute.keys()].toSorted();
|
|
219
|
+
|
|
220
|
+
for (const [index, leftKey] of keys.entries()) {
|
|
221
|
+
for (const rightKey of keys.slice(index + 1)) {
|
|
222
|
+
const leftClaims = claimsByRoute.get(leftKey) ?? [];
|
|
223
|
+
const rightClaims = claimsByRoute.get(rightKey) ?? [];
|
|
224
|
+
const [leftFirst] = leftClaims;
|
|
225
|
+
const [rightFirst] = rightClaims;
|
|
226
|
+
if (leftFirst === undefined || rightFirst === undefined) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (leftFirst.method !== rightFirst.method) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
!(
|
|
234
|
+
hasDynamicSegment(leftFirst.path) ||
|
|
235
|
+
hasDynamicSegment(rightFirst.path)
|
|
236
|
+
)
|
|
237
|
+
) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const participants = [...leftClaims, ...rightClaims];
|
|
241
|
+
if (!participants.some((claim) => claim.type === 'webhook')) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!webhookPathPatternsOverlap(leftFirst.path, rightFirst.path)) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
diagnostics.push({
|
|
248
|
+
filePath: TOPO_FILE,
|
|
249
|
+
line: 1,
|
|
250
|
+
message: `HTTP webhook route pattern overlap between ${leftKey} and ${rightKey}: ${sortedClaims(
|
|
251
|
+
participants
|
|
252
|
+
)
|
|
253
|
+
.map(claimLabel)
|
|
254
|
+
.join(
|
|
255
|
+
', '
|
|
256
|
+
)}. Both patterns can match one request path; make the patterns disjoint or merge the sources.`,
|
|
257
|
+
rule: RULE_NAME,
|
|
258
|
+
severity: 'error',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return diagnostics;
|
|
263
|
+
};
|
|
264
|
+
|
|
203
265
|
const buildDiagnostics = (claims: readonly RouteClaim[]) => {
|
|
204
266
|
const claimsByRoute = new Map<string, RouteClaim[]>();
|
|
205
267
|
for (const claim of claims) {
|
|
@@ -227,6 +289,7 @@ const buildDiagnostics = (claims: readonly RouteClaim[]) => {
|
|
|
227
289
|
}
|
|
228
290
|
diagnostics.push(...collectPolicyMismatchDiagnostics(key, webhookClaims));
|
|
229
291
|
}
|
|
292
|
+
diagnostics.push(...collectPatternOverlapDiagnostics(claimsByRoute));
|
|
230
293
|
return diagnostics;
|
|
231
294
|
};
|
|
232
295
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { duplicateExportedSymbol } from '../rules/duplicate-exported-symbol.js';
|
|
2
|
+
import { wrapRule } from './wrap-rule.js';
|
|
3
|
+
|
|
4
|
+
export const duplicateExportedSymbolTrail = wrapRule({
|
|
5
|
+
examples: [
|
|
6
|
+
{
|
|
7
|
+
expected: {
|
|
8
|
+
diagnostics: [
|
|
9
|
+
{
|
|
10
|
+
filePath: '/repo/packages/core/src/index.ts',
|
|
11
|
+
line: 1,
|
|
12
|
+
message:
|
|
13
|
+
'Exported symbol "createClient" is defined by @ontrails/core and also by @ontrails/store (/repo/packages/store/src/index.ts:1). Keep one package as the owner, rename one side, or document a deliberate ownership mirror before exporting both symbols.',
|
|
14
|
+
rule: 'duplicate-exported-symbol',
|
|
15
|
+
severity: 'warn',
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
input: {
|
|
20
|
+
exportedSymbolDefinitionsByName: {
|
|
21
|
+
createClient: [
|
|
22
|
+
{
|
|
23
|
+
filePath: '/repo/packages/core/src/index.ts',
|
|
24
|
+
kind: 'function',
|
|
25
|
+
line: 1,
|
|
26
|
+
name: 'createClient',
|
|
27
|
+
workspaceName: '@ontrails/core',
|
|
28
|
+
workspaceRoot: '/repo/packages/core',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
filePath: '/repo/packages/store/src/index.ts',
|
|
32
|
+
kind: 'function',
|
|
33
|
+
line: 1,
|
|
34
|
+
name: 'createClient',
|
|
35
|
+
workspaceName: '@ontrails/store',
|
|
36
|
+
workspaceRoot: '/repo/packages/store',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
filePath: '/repo/packages/core/src/index.ts',
|
|
41
|
+
knownTrailIds: [],
|
|
42
|
+
sourceCode: 'export function createClient() {}',
|
|
43
|
+
},
|
|
44
|
+
name: 'Flags duplicate exported symbol definitions across packages',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
rule: duplicateExportedSymbol,
|
|
48
|
+
});
|
|
@@ -28,7 +28,7 @@ export const duplicatePublicContractTrail = wrapTopoRule({
|
|
|
28
28
|
filePath: '<topo>',
|
|
29
29
|
line: 1,
|
|
30
30
|
message:
|
|
31
|
-
'Likely duplicate public trail contracts "diff", "survey.diff" share the same input, output, intent, permits, resources, composes, signals, and detours. Keep one contract with aliases/input mappings, compose a distinct wrapper, or document why these public contracts are separate.',
|
|
31
|
+
'Likely duplicate public trail contracts "diff", "survey.diff" share the same input, output, intent, permits, resources, contours, composes, signals, and detours. Keep one contract with aliases/input mappings, compose a distinct wrapper, or document why these public contracts are separate.',
|
|
32
32
|
rule: 'duplicate-public-contract',
|
|
33
33
|
severity: 'warn',
|
|
34
34
|
},
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { governedSymbolResidue } from '../rules/governed-symbol-residue.js';
|
|
2
|
+
import { wrapRule } from './wrap-rule.js';
|
|
3
|
+
|
|
4
|
+
export const governedSymbolResidueTrail = wrapRule({
|
|
5
|
+
examples: [
|
|
6
|
+
{
|
|
7
|
+
expected: { diagnostics: [] },
|
|
8
|
+
input: {
|
|
9
|
+
filePath: 'packages/example/src/trails/play.ts',
|
|
10
|
+
sourceCode: 'const composeInput = { id: "track" };\n',
|
|
11
|
+
},
|
|
12
|
+
name: 'Current governed symbol vocabulary is clean',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
expected: { diagnostics: [] },
|
|
16
|
+
input: {
|
|
17
|
+
filePath: 'packages/example/src/trails/play.ts',
|
|
18
|
+
sourceCode: 'const crossInput = { id: "track" };\n',
|
|
19
|
+
},
|
|
20
|
+
name: 'Cross-compose vocabulary is owned by the beta.19 rule',
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
rule: governedSymbolResidue,
|
|
24
|
+
});
|
package/src/trails/index.ts
CHANGED
|
@@ -10,10 +10,12 @@ export { deprecationWithoutGuidanceTrail } from './deprecation-without-guidance.
|
|
|
10
10
|
export { duplicatePublicContractTrail } from './duplicate-public-contract.trail.js';
|
|
11
11
|
export { draftFileMarkingTrail } from './draft-file-marking.trail.js';
|
|
12
12
|
export { draftVisibleDebtTrail } from './draft-visible-debt.trail.js';
|
|
13
|
+
export { duplicateExportedSymbolTrail } from './duplicate-exported-symbol.trail.js';
|
|
13
14
|
export { errorMappingCompletenessTrail } from './error-mapping-completeness.trail.js';
|
|
14
15
|
export { exampleValidTrail } from './example-valid.trail.js';
|
|
15
16
|
export { firesDeclarationsTrail } from './fires-declarations.trail.js';
|
|
16
17
|
export { forkWithoutPreservedBlazeTrail } from './fork-without-preserved-blaze.trail.js';
|
|
18
|
+
export { governedSymbolResidueTrail } from './governed-symbol-residue.trail.js';
|
|
17
19
|
export { implementationReturnsResultTrail } from './implementation-returns-result.trail.js';
|
|
18
20
|
export { incompleteAccessorForStandardOpTrail } from './incomplete-accessor-for-standard-op.trail.js';
|
|
19
21
|
export { incompleteCrudTrail } from './incomplete-crud.trail.js';
|
|
@@ -26,6 +28,7 @@ export { missingReconcileTrail } from './missing-reconcile.trail.js';
|
|
|
26
28
|
export { onReferencesExistTrail } from './on-references-exist.trail.js';
|
|
27
29
|
export { noDevPermitInSourceTrail } from './no-dev-permit-in-source.trail.js';
|
|
28
30
|
export { noDestructuredComposeTrail } from './no-destructured-compose.trail.js';
|
|
31
|
+
export { noLegacyCliAliasExportTrail } from './no-legacy-cli-alias-export.trail.js';
|
|
29
32
|
export { noLegacyLayerImportsTrail } from './no-legacy-layer-imports.trail.js';
|
|
30
33
|
export { noDirectImplementationCallTrail } from './no-direct-implementation-call.trail.js';
|
|
31
34
|
export { noNativeErrorResultTrail } from './no-native-error-result.trail.js';
|
|
@@ -54,8 +57,10 @@ export { resourceMockCoverageTrail } from './resource-mock-coverage.trail.js';
|
|
|
54
57
|
export { scheduledDestroyIntentTrail } from './scheduled-destroy-intent.trail.js';
|
|
55
58
|
export { signalGraphCoachingTrail } from './signal-graph-coaching.trail.js';
|
|
56
59
|
export { staticResourceAccessorPreferenceTrail } from './static-resource-accessor-preference.trail.js';
|
|
57
|
-
export {
|
|
60
|
+
export { surfaceOverlayCoherenceTrail } from './surface-overlay-coherence.trail.js';
|
|
61
|
+
export { surfaceTrailheadCoherenceTrail } from './surface-trailhead-coherence.trail.js';
|
|
58
62
|
export { trailForkCoachingTrail } from './trail-fork-coaching.trail.js';
|
|
63
|
+
export { trailheadOverrideDivergenceTrail } from './trailhead-override-divergence.trail.js';
|
|
59
64
|
export { unmaterializedActivationSourceTrail } from './unmaterialized-activation-source.trail.js';
|
|
60
65
|
export { unreachableDetourShadowingTrail } from './unreachable-detour-shadowing.trail.js';
|
|
61
66
|
export { validDetourContractTrail } from './valid-detour-contract.trail.js';
|