@ontrails/warden 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +1556 -0
  2. package/README.md +200 -0
  3. package/bin/warden.ts +51 -0
  4. package/package.json +51 -0
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +2159 -0
  7. package/src/command.ts +1167 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +260 -0
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +178 -0
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +295 -0
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/regrade-history.ts +188 -0
  18. package/src/regrade-receipt.ts +484 -0
  19. package/src/resolve.ts +531 -0
  20. package/src/rules/activation-orphan.ts +97 -0
  21. package/src/rules/captured-kernel.ts +375 -0
  22. package/src/rules/circular-refs.ts +150 -0
  23. package/src/rules/cli-command-route-coherence.ts +177 -0
  24. package/src/rules/composes-declarations.ts +839 -0
  25. package/src/rules/context-no-surface-types.ts +214 -0
  26. package/src/rules/dead-internal-trail.ts +161 -0
  27. package/src/rules/dead-public-trail.ts +258 -0
  28. package/src/rules/draft-file-marking.ts +155 -0
  29. package/src/rules/draft-visible-debt.ts +83 -0
  30. package/src/rules/duplicate-exported-symbol.ts +172 -0
  31. package/src/rules/duplicate-public-contract.ts +137 -0
  32. package/src/rules/entity-exists.ts +254 -0
  33. package/src/rules/entity-ids.ts +15 -0
  34. package/src/rules/error-mapping-completeness.ts +290 -0
  35. package/src/rules/example-valid.ts +395 -0
  36. package/src/rules/fires-declarations.ts +740 -0
  37. package/src/rules/governed-symbol-residue.ts +535 -0
  38. package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
  39. package/src/rules/implementation-returns-result.ts +1733 -0
  40. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  41. package/src/rules/incomplete-crud.ts +583 -0
  42. package/src/rules/index.ts +328 -0
  43. package/src/rules/intent-propagation.ts +125 -0
  44. package/src/rules/layer-field-name-drift.ts +102 -0
  45. package/src/rules/library-render-coherence.ts +97 -0
  46. package/src/rules/metadata.ts +929 -0
  47. package/src/rules/missing-reconcile.ts +97 -0
  48. package/src/rules/missing-visibility.ts +111 -0
  49. package/src/rules/no-destructured-compose.ts +196 -0
  50. package/src/rules/no-dev-permit-in-source.ts +99 -0
  51. package/src/rules/no-direct-implementation-call.ts +52 -0
  52. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  53. package/src/rules/no-legacy-layer-imports.ts +211 -0
  54. package/src/rules/no-native-error-result.ts +118 -0
  55. package/src/rules/no-redundant-result-error-wrap.ts +382 -0
  56. package/src/rules/no-retired-cross-vocabulary.ts +203 -0
  57. package/src/rules/no-sync-result-assumption.ts +1199 -0
  58. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  59. package/src/rules/no-throw-in-implementation.ts +48 -0
  60. package/src/rules/no-top-level-surface.ts +371 -0
  61. package/src/rules/on-references-exist.ts +194 -0
  62. package/src/rules/orphaned-signal.ts +149 -0
  63. package/src/rules/owner-render-parity.ts +143 -0
  64. package/src/rules/permit-governance.ts +25 -0
  65. package/src/rules/prefer-schema-inference.ts +141 -0
  66. package/src/rules/public-export-example-coverage.ts +561 -0
  67. package/src/rules/public-internal-deep-imports.ts +454 -0
  68. package/src/rules/public-output-schema.ts +29 -0
  69. package/src/rules/public-union-output-discriminants.ts +150 -0
  70. package/src/rules/read-intent-fires.ts +188 -0
  71. package/src/rules/reference-exists.ts +97 -0
  72. package/src/rules/registry-names.ts +171 -0
  73. package/src/rules/resolved-import-boundary.ts +146 -0
  74. package/src/rules/resource-declarations.ts +697 -0
  75. package/src/rules/resource-exists.ts +181 -0
  76. package/src/rules/resource-id-grammar.ts +65 -0
  77. package/src/rules/resource-mock-coverage.ts +115 -0
  78. package/src/rules/retired-vocabulary.ts +1407 -0
  79. package/src/rules/scan.ts +59 -0
  80. package/src/rules/scheduled-destroy-intent.ts +44 -0
  81. package/src/rules/signal-graph-coaching.ts +220 -0
  82. package/src/rules/source/composition.ts +165 -0
  83. package/src/rules/source/drafts.ts +164 -0
  84. package/src/rules/source/entities.ts +618 -0
  85. package/src/rules/source/pragmas.ts +45 -0
  86. package/src/rules/source/resources.ts +64 -0
  87. package/src/rules/source/signals.ts +397 -0
  88. package/src/rules/source/stores.ts +310 -0
  89. package/src/rules/specs.ts +388 -0
  90. package/src/rules/static-resource-accessor-preference.ts +654 -0
  91. package/src/rules/structure.ts +234 -0
  92. package/src/rules/surface-overlay-coherence.ts +262 -0
  93. package/src/rules/surface-trailhead-coherence.ts +366 -0
  94. package/src/rules/trail-fork-coaching.ts +625 -0
  95. package/src/rules/trail-versioning-source.ts +1076 -0
  96. package/src/rules/trail-versioning-topo.ts +172 -0
  97. package/src/rules/trailhead-override-divergence.ts +356 -0
  98. package/src/rules/types.ts +464 -0
  99. package/src/rules/unmaterialized-activation-source.ts +85 -0
  100. package/src/rules/unreachable-detour-shadowing.ts +343 -0
  101. package/src/rules/valid-describe-refs.ts +224 -0
  102. package/src/rules/valid-detour-contract.ts +78 -0
  103. package/src/rules/warden-export-symmetry.ts +540 -0
  104. package/src/rules/warden-rules-use-ast.ts +1109 -0
  105. package/src/rules/webhook-route-collision.ts +306 -0
  106. package/src/rules/workspace-lock-ownership.ts +34 -0
  107. package/src/trails/activation-orphan.trail.ts +84 -0
  108. package/src/trails/captured-kernel.trail.ts +108 -0
  109. package/src/trails/circular-refs.trail.ts +29 -0
  110. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  111. package/src/trails/composes-declarations.trail.ts +22 -0
  112. package/src/trails/context-no-surface-types.trail.ts +21 -0
  113. package/src/trails/dead-internal-trail.trail.ts +26 -0
  114. package/src/trails/dead-public-trail.trail.ts +31 -0
  115. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  116. package/src/trails/draft-file-marking.trail.ts +16 -0
  117. package/src/trails/draft-visible-debt.trail.ts +16 -0
  118. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  119. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  120. package/src/trails/entity-exists.trail.ts +21 -0
  121. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  122. package/src/trails/example-valid.trail.ts +25 -0
  123. package/src/trails/fires-declarations.trail.ts +23 -0
  124. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  125. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  126. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  127. package/src/trails/implementation-returns-result.trail.ts +20 -0
  128. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  129. package/src/trails/incomplete-crud.trail.ts +39 -0
  130. package/src/trails/index.ts +91 -0
  131. package/src/trails/intent-propagation.trail.ts +30 -0
  132. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  133. package/src/trails/library-render-coherence.trail.ts +43 -0
  134. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  135. package/src/trails/missing-reconcile.trail.ts +33 -0
  136. package/src/trails/missing-visibility.trail.ts +22 -0
  137. package/src/trails/no-destructured-compose.trail.ts +44 -0
  138. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  139. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  140. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  141. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  142. package/src/trails/no-native-error-result.trail.ts +18 -0
  143. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  144. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  145. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  146. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  147. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  148. package/src/trails/no-top-level-surface.trail.ts +43 -0
  149. package/src/trails/on-references-exist.trail.ts +21 -0
  150. package/src/trails/orphaned-signal.trail.ts +36 -0
  151. package/src/trails/owner-render-parity.trail.ts +26 -0
  152. package/src/trails/pending-force.trail.ts +21 -0
  153. package/src/trails/permit-governance.trail.ts +51 -0
  154. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  155. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  156. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  157. package/src/trails/public-output-schema.trail.ts +55 -0
  158. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  159. package/src/trails/read-intent-fires.trail.ts +20 -0
  160. package/src/trails/reference-exists.trail.ts +25 -0
  161. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  162. package/src/trails/resource-declarations.trail.ts +25 -0
  163. package/src/trails/resource-exists.trail.ts +27 -0
  164. package/src/trails/resource-id-grammar.trail.ts +39 -0
  165. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  166. package/src/trails/run.ts +205 -0
  167. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  168. package/src/trails/schema.ts +295 -0
  169. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  170. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  171. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  172. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  173. package/src/trails/topo.ts +6 -0
  174. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  175. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  176. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  177. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  178. package/src/trails/valid-describe-refs.trail.ts +18 -0
  179. package/src/trails/valid-detour-contract.trail.ts +71 -0
  180. package/src/trails/version-gap.trail.ts +35 -0
  181. package/src/trails/version-pinned-compose.trail.ts +23 -0
  182. package/src/trails/version-without-examples.trail.ts +38 -0
  183. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  184. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  185. package/src/trails/webhook-route-collision.trail.ts +50 -0
  186. package/src/trails/workspace-lock-ownership.trail.ts +16 -0
  187. package/src/trails/wrap-rule.ts +269 -0
  188. package/src/workspaces.ts +264 -0
@@ -0,0 +1,234 @@
1
+ type QuoteMode = '"' | "'" | '`' | null;
2
+
3
+ interface ScanState {
4
+ braceDepth: number;
5
+ bracketDepth: number;
6
+ escaped: boolean;
7
+ parenDepth: number;
8
+ quoteMode: QuoteMode;
9
+ }
10
+
11
+ type DepthKey = 'braceDepth' | 'bracketDepth' | 'parenDepth';
12
+
13
+ interface BalancedSegment {
14
+ readonly end: number;
15
+ readonly text: string;
16
+ }
17
+
18
+ export interface SplitEntry {
19
+ readonly start: number;
20
+ readonly text: string;
21
+ }
22
+
23
+ const createState = (): ScanState => ({
24
+ braceDepth: 0,
25
+ bracketDepth: 0,
26
+ escaped: false,
27
+ parenDepth: 0,
28
+ quoteMode: null,
29
+ });
30
+
31
+ const enterQuote = (ch: string): QuoteMode => {
32
+ if (ch === "'" || ch === '"' || ch === '`') {
33
+ return ch;
34
+ }
35
+ return null;
36
+ };
37
+
38
+ const clearEscape = (state: ScanState): boolean => {
39
+ if (!state.escaped) {
40
+ return false;
41
+ }
42
+
43
+ state.escaped = false;
44
+ return true;
45
+ };
46
+
47
+ const beginEscape = (state: ScanState, ch: string): boolean => {
48
+ if (ch !== '\\') {
49
+ return false;
50
+ }
51
+
52
+ state.escaped = true;
53
+ return true;
54
+ };
55
+
56
+ const closeQuote = (state: ScanState, ch: string): void => {
57
+ if (ch === state.quoteMode) {
58
+ state.quoteMode = null;
59
+ }
60
+ };
61
+
62
+ const updateQuotedState = (state: ScanState, ch: string): boolean => {
63
+ if (state.quoteMode === null) {
64
+ return false;
65
+ }
66
+
67
+ if (clearEscape(state)) {
68
+ return true;
69
+ }
70
+
71
+ if (beginEscape(state, ch)) {
72
+ return true;
73
+ }
74
+
75
+ closeQuote(state, ch);
76
+ return true;
77
+ };
78
+
79
+ const STRUCTURAL_DELTAS = {
80
+ '(': ['parenDepth', 1],
81
+ ')': ['parenDepth', -1],
82
+ '[': ['bracketDepth', 1],
83
+ ']': ['bracketDepth', -1],
84
+ '{': ['braceDepth', 1],
85
+ '}': ['braceDepth', -1],
86
+ } as const satisfies Record<string, readonly [DepthKey, number]>;
87
+
88
+ const updateStructuralDepth = (state: ScanState, ch: string): void => {
89
+ if (!(ch in STRUCTURAL_DELTAS)) {
90
+ return;
91
+ }
92
+
93
+ const delta = STRUCTURAL_DELTAS[ch as keyof typeof STRUCTURAL_DELTAS];
94
+ const [key, amount] = delta;
95
+ state[key] += amount;
96
+ };
97
+
98
+ const isTopLevel = (state: ScanState): boolean =>
99
+ state.braceDepth === 0 &&
100
+ state.bracketDepth === 0 &&
101
+ state.parenDepth === 0 &&
102
+ state.quoteMode === null;
103
+
104
+ const scanCharacter = (state: ScanState, ch: string): void => {
105
+ if (updateQuotedState(state, ch)) {
106
+ return;
107
+ }
108
+
109
+ const nextQuoteMode = enterQuote(ch);
110
+ if (nextQuoteMode !== null) {
111
+ state.quoteMode = nextQuoteMode;
112
+ return;
113
+ }
114
+
115
+ updateStructuralDepth(state, ch);
116
+ };
117
+
118
+ const isBalancedOpener = (ch: string | undefined): boolean =>
119
+ ch === '{' || ch === '[' || ch === '(';
120
+
121
+ const appendBalancedCharacter = (
122
+ sourceText: string,
123
+ state: ScanState,
124
+ index: number,
125
+ text: string
126
+ ): string => {
127
+ const ch = sourceText[index];
128
+ if (!ch) {
129
+ return text;
130
+ }
131
+
132
+ scanCharacter(state, ch);
133
+ return `${text}${ch}`;
134
+ };
135
+
136
+ export const captureBalanced = (
137
+ sourceText: string,
138
+ startIndex: number
139
+ ): BalancedSegment | null => {
140
+ if (!isBalancedOpener(sourceText[startIndex])) {
141
+ return null;
142
+ }
143
+
144
+ const state = createState();
145
+ let text = '';
146
+
147
+ for (let index = startIndex; index < sourceText.length; index += 1) {
148
+ text = appendBalancedCharacter(sourceText, state, index, text);
149
+
150
+ if (index > startIndex && isTopLevel(state)) {
151
+ return { end: index, text };
152
+ }
153
+ }
154
+
155
+ return null;
156
+ };
157
+
158
+ export const lineNumberAt = (sourceText: string, startIndex: number): number =>
159
+ sourceText.slice(0, startIndex).split('\n').length;
160
+
161
+ const createSplitEntry = (
162
+ sourceText: string,
163
+ startIndex: number,
164
+ endIndex: number
165
+ ): SplitEntry | null => {
166
+ const raw = sourceText.slice(startIndex, endIndex);
167
+ const firstContent = raw.search(/\S/);
168
+ if (firstContent === -1) {
169
+ return null;
170
+ }
171
+
172
+ const trailingWhitespace = raw.match(/\s*$/)?.[0].length ?? 0;
173
+ const trimmedEnd = raw.length - trailingWhitespace;
174
+
175
+ return {
176
+ start: startIndex + firstContent,
177
+ text: raw.slice(firstContent, trimmedEnd),
178
+ };
179
+ };
180
+
181
+ const pushSplitEntry = (
182
+ entries: SplitEntry[],
183
+ sourceText: string,
184
+ startIndex: number,
185
+ endIndex: number
186
+ ): void => {
187
+ const entry = createSplitEntry(sourceText, startIndex, endIndex);
188
+ if (entry !== null) {
189
+ entries.push(entry);
190
+ }
191
+ };
192
+
193
+ const processSplitCharacter = (
194
+ entries: SplitEntry[],
195
+ sourceText: string,
196
+ state: ScanState,
197
+ entryStart: number,
198
+ index: number
199
+ ): number => {
200
+ const ch = sourceText[index];
201
+ if (!ch) {
202
+ return entryStart;
203
+ }
204
+
205
+ if (ch === ',' && isTopLevel(state)) {
206
+ pushSplitEntry(entries, sourceText, entryStart, index);
207
+ return index + 1;
208
+ }
209
+
210
+ scanCharacter(state, ch);
211
+ return entryStart;
212
+ };
213
+
214
+ export const splitTopLevelEntriesWithOffsets = (
215
+ sourceText: string
216
+ ): SplitEntry[] => {
217
+ const entries: SplitEntry[] = [];
218
+ const state = createState();
219
+ let entryStart = 0;
220
+
221
+ for (let index = 0; index < sourceText.length; index += 1) {
222
+ entryStart = processSplitCharacter(
223
+ entries,
224
+ sourceText,
225
+ state,
226
+ entryStart,
227
+ index
228
+ );
229
+ }
230
+
231
+ pushSplitEntry(entries, sourceText, entryStart, sourceText.length);
232
+
233
+ return entries;
234
+ };
@@ -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/topography';
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 renderings 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
+ };