@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +1 -1
  3. package/package.json +9 -9
  4. package/src/cli.ts +120 -10
  5. package/src/command.ts +25 -5
  6. package/src/drift.ts +116 -7
  7. package/src/fix.ts +8 -2
  8. package/src/formatters.ts +3 -8
  9. package/src/index.ts +30 -2
  10. package/src/project-context.ts +255 -1
  11. package/src/rules/ast.ts +4 -0
  12. package/src/rules/duplicate-exported-symbol.ts +211 -0
  13. package/src/rules/duplicate-public-contract.ts +2 -1
  14. package/src/rules/governed-symbol-residue.ts +131 -0
  15. package/src/rules/index.ts +43 -3
  16. package/src/rules/metadata.ts +105 -11
  17. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  18. package/src/rules/no-retired-cross-vocabulary.ts +19 -9
  19. package/src/rules/public-export-example-coverage.ts +5 -0
  20. package/src/rules/registry-names.ts +12 -2
  21. package/src/rules/retired-vocabulary.ts +396 -0
  22. package/src/rules/signal-graph-coaching.ts +32 -3
  23. package/src/rules/surface-overlay-coherence.ts +262 -0
  24. package/src/rules/{surface-facet-coherence.ts → surface-trailhead-coherence.ts} +45 -41
  25. package/src/rules/trailhead-override-divergence.ts +356 -0
  26. package/src/rules/types.ts +58 -1
  27. package/src/rules/webhook-route-collision.ts +64 -1
  28. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  29. package/src/trails/duplicate-public-contract.trail.ts +1 -1
  30. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  31. package/src/trails/index.ts +6 -1
  32. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  33. package/src/trails/schema.ts +39 -0
  34. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  35. package/src/trails/{surface-facet-coherence.trail.ts → surface-trailhead-coherence.trail.ts} +4 -4
  36. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  37. 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
+ };
@@ -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 { shouldIncludeTrailForSurface } from '@ontrails/core';
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
+ });
@@ -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 { surfaceFacetCoherenceTrail } from './surface-facet-coherence.trail.js';
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';