@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,561 @@
1
+ /**
2
+ * Repo-local rule (TRL-943): public API exports re-exported from the v1
3
+ * surface package index barrels must carry a leading `@example` TSDoc block
4
+ * on their exported declaration. Graduated from
5
+ * `scripts/check-public-api-examples.ts` so the contract is governed by
6
+ * Warden instead of a standalone script.
7
+ *
8
+ * The inventory mirrors the script's semantics:
9
+ *
10
+ * 1. Only non-type-only named re-exports with relative module specifiers
11
+ * are inventoried. Type-only export declarations and type-only
12
+ * specifiers are skipped.
13
+ * 2. Star re-exports, non-relative specifiers, and local export lists on a
14
+ * target barrel are reported as errors — the inventory cannot resolve
15
+ * them to a declaration.
16
+ * 3. Each re-export resolves to its source module (`.js` → `.ts`, relative
17
+ * to the barrel), and the exported declaration is located by the
18
+ * IMPORTED name (the `propertyName` when aliased). The declaration is
19
+ * covered when a leading comment in the trivia gap before it matches
20
+ * `@example`.
21
+ * 4. A `minimumExports` entry that never appears in the barrel inventory is
22
+ * an error — the minimum policy list must stay inventoried.
23
+ *
24
+ * Severity model: missing `@example` on a `minimumExports` entry is an
25
+ * `error`; missing `@example` on any other inventoried export is a `warn`
26
+ * so the rest of the inventory stays visible without failing
27
+ * `failOn: 'error'` runs.
28
+ */
29
+ import { readFileSync } from 'node:fs';
30
+ import { dirname, join, normalize, relative, resolve } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ import {
34
+ getNodeBodyStatements,
35
+ getNodeDeclaration,
36
+ getNodeDeclarations,
37
+ getNodeExported,
38
+ getNodeExportKind,
39
+ getNodeId,
40
+ getNodeLocal,
41
+ getNodeName,
42
+ getNodeSource,
43
+ getNodeSpecifiers,
44
+ getNodeValue,
45
+ offsetToLine,
46
+ parse,
47
+ parseWithDiagnostics,
48
+ } from '@ontrails/source';
49
+ import type { AstNode, SourceComment } from '@ontrails/source';
50
+ import type { WardenDiagnostic, WardenRule } from './types.js';
51
+
52
+ const RULE_NAME = 'public-export-example-coverage';
53
+
54
+ export interface PublicApiPackageTarget {
55
+ /** Repo-root-relative path to the package's public index barrel. */
56
+ readonly indexPath: string;
57
+ /** Exports that MUST be inventoried and carry `@example` coverage. */
58
+ readonly minimumExports: readonly string[];
59
+ /** Published package name, used in diagnostics. */
60
+ readonly packageName: string;
61
+ }
62
+
63
+ /**
64
+ * Repo-local public API `@example` coverage policy.
65
+ *
66
+ * Ported verbatim from `scripts/check-public-api-examples.ts`
67
+ * (`PUBLIC_API_EXAMPLE_TARGETS`). This table lives in the rule module as
68
+ * repo-local policy: `wardenConfigSchema` is a strict runner-options schema
69
+ * and source-static rules receive only `(sourceCode, filePath)`, so there is
70
+ * no per-rule config channel today. Move the table into Warden config if a
71
+ * per-rule config channel lands.
72
+ */
73
+ export const PUBLIC_API_EXAMPLE_TARGETS: readonly PublicApiPackageTarget[] = [
74
+ {
75
+ indexPath: 'packages/cli/src/index.ts',
76
+ minimumExports: [
77
+ 'deriveCliCommands',
78
+ 'deriveFlags',
79
+ 'output',
80
+ 'deriveOutputMode',
81
+ 'findAppModuleCandidates',
82
+ 'findAppModule',
83
+ ],
84
+ packageName: '@ontrails/cli',
85
+ },
86
+ {
87
+ indexPath: 'packages/http/src/index.ts',
88
+ minimumExports: [
89
+ 'deriveHttpRoutes',
90
+ 'deriveHttpInputSource',
91
+ 'deriveHttpMethod',
92
+ 'deriveHttpOperationMethod',
93
+ 'deriveOpenApiSpec',
94
+ ],
95
+ packageName: '@ontrails/http',
96
+ },
97
+ {
98
+ indexPath: 'packages/mcp/src/index.ts',
99
+ minimumExports: [
100
+ 'deriveMcpTools',
101
+ 'createServer',
102
+ 'surface',
103
+ 'connectStdio',
104
+ ],
105
+ packageName: '@ontrails/mcp',
106
+ },
107
+ {
108
+ indexPath: 'adapters/commander/src/index.ts',
109
+ minimumExports: ['createProgram', 'surface', 'toCommander'],
110
+ packageName: '@ontrails/commander',
111
+ },
112
+ {
113
+ indexPath: 'adapters/hono/src/index.ts',
114
+ minimumExports: ['createApp', 'surface'],
115
+ packageName: '@ontrails/hono',
116
+ },
117
+ {
118
+ indexPath: 'adapters/cloudflare/src/index.ts',
119
+ minimumExports: [
120
+ 'createWorkersHandler',
121
+ 'cloudflareKv',
122
+ 'cloudflareD1',
123
+ 'cloudflareQueue',
124
+ 'cloudflareR2',
125
+ ],
126
+ packageName: '@ontrails/cloudflare',
127
+ },
128
+ ] as const;
129
+
130
+ export interface ResolvedPublicApiTarget extends PublicApiPackageTarget {
131
+ /** Absolute path of the target barrel — the rule's path anchor. */
132
+ readonly absoluteIndexPath: string;
133
+ /** Absolute repo (or fixture) root used to relativize diagnostic paths. */
134
+ readonly rootDir: string;
135
+ }
136
+
137
+ /**
138
+ * Resolve repo-relative policy targets against a root directory. Exported
139
+ * for unit testing — tests build fixture trees under a temp root instead of
140
+ * depending on the real repo barrels. Not part of the public rule API.
141
+ */
142
+ export const resolvePublicApiExampleTargets = (
143
+ rootDir: string,
144
+ targets: readonly PublicApiPackageTarget[]
145
+ ): readonly ResolvedPublicApiTarget[] => {
146
+ const resolvedRoot = resolve(rootDir);
147
+ return targets.map((target) => ({
148
+ ...target,
149
+ absoluteIndexPath: resolve(resolvedRoot, target.indexPath),
150
+ rootDir: resolvedRoot,
151
+ }));
152
+ };
153
+
154
+ /**
155
+ * Repo root resolved from this rule's own module URL
156
+ * (`packages/warden/src/rules/` → four levels up). Anchoring to the real
157
+ * on-disk location gives the same consumer-repo safety property as
158
+ * `warden-export-symmetry`'s SELF_BARREL_PATH: in a consumer repository the
159
+ * warden package resolves inside `node_modules`, so the computed absolute
160
+ * target paths never match consumer files and the rule stays silent.
161
+ */
162
+ const REPO_ROOT = resolve(
163
+ fileURLToPath(new URL('../../../..', import.meta.url))
164
+ );
165
+
166
+ const RESOLVED_TARGETS = resolvePublicApiExampleTargets(
167
+ REPO_ROOT,
168
+ PUBLIC_API_EXAMPLE_TARGETS
169
+ );
170
+
171
+ interface PublicExportSpecifier {
172
+ /** Public export name as seen on the barrel. */
173
+ readonly exportName: string;
174
+ /** Local source binding name (`propertyName` when aliased). */
175
+ readonly importedName: string;
176
+ readonly moduleSpecifier: string;
177
+ /** Start offset of the export specifier on the barrel. */
178
+ readonly start: number;
179
+ }
180
+
181
+ interface BarrelInventory {
182
+ readonly diagnostics: readonly WardenDiagnostic[];
183
+ readonly specifiers: readonly PublicExportSpecifier[];
184
+ }
185
+
186
+ const isTypeKind = (node: AstNode): boolean =>
187
+ getNodeExportKind(node) === 'type';
188
+
189
+ const readNameNode = (node: AstNode | undefined): string | null => {
190
+ if (!node) {
191
+ return null;
192
+ }
193
+ if (node.type === 'Identifier') {
194
+ return getNodeName(node) ?? null;
195
+ }
196
+ if (node.type === 'Literal' || node.type === 'StringLiteral') {
197
+ const value = getNodeValue(node);
198
+ return typeof value === 'string' ? value : null;
199
+ }
200
+ return null;
201
+ };
202
+
203
+ const moduleSpecifierValue = (node: AstNode): string | null => {
204
+ const source = getNodeSource(node);
205
+ if (!source) {
206
+ return null;
207
+ }
208
+ const value = getNodeValue(source);
209
+ return typeof value === 'string' ? value : null;
210
+ };
211
+
212
+ const programBody = (ast: AstNode): readonly AstNode[] =>
213
+ getNodeBodyStatements(ast);
214
+
215
+ const diagnostic = (
216
+ sourceCode: string,
217
+ filePath: string,
218
+ start: number,
219
+ severity: WardenDiagnostic['severity'],
220
+ message: string
221
+ ): WardenDiagnostic => ({
222
+ filePath,
223
+ line: offsetToLine(sourceCode, start),
224
+ message: `${RULE_NAME}: ${message}`,
225
+ rule: RULE_NAME,
226
+ severity,
227
+ });
228
+
229
+ const specifiersFromExportDeclaration = (
230
+ node: AstNode,
231
+ moduleSpecifier: string
232
+ ): readonly PublicExportSpecifier[] => {
233
+ const specifiers = getNodeSpecifiers(node) ?? [];
234
+ return specifiers.flatMap((specifier) => {
235
+ if (specifier.type !== 'ExportSpecifier' || isTypeKind(specifier)) {
236
+ return [];
237
+ }
238
+ const exported = getNodeExported(specifier);
239
+ const local = getNodeLocal(specifier);
240
+ const exportName = readNameNode(exported);
241
+ if (!exportName) {
242
+ return [];
243
+ }
244
+ return [
245
+ {
246
+ exportName,
247
+ importedName: readNameNode(local) ?? exportName,
248
+ moduleSpecifier,
249
+ start: specifier.start,
250
+ },
251
+ ];
252
+ });
253
+ };
254
+
255
+ interface InventoryContext {
256
+ readonly diagnostics: WardenDiagnostic[];
257
+ readonly filePath: string;
258
+ readonly sourceCode: string;
259
+ readonly specifiers: PublicExportSpecifier[];
260
+ readonly target: ResolvedPublicApiTarget;
261
+ }
262
+
263
+ const inventoryNamedExport = (node: AstNode, ctx: InventoryContext): void => {
264
+ if (isTypeKind(node)) {
265
+ return;
266
+ }
267
+ const declaration = getNodeDeclaration(node);
268
+ if (declaration) {
269
+ // Declaration-form exports (`export const foo = ...`) are not module
270
+ // re-exports; the script's inventory skipped them the same way.
271
+ return;
272
+ }
273
+ const moduleSpecifier = moduleSpecifierValue(node);
274
+ if (moduleSpecifier === null) {
275
+ ctx.diagnostics.push(
276
+ diagnostic(
277
+ ctx.sourceCode,
278
+ ctx.filePath,
279
+ node.start,
280
+ 'error',
281
+ `${ctx.target.packageName} barrel has a local export list without a module specifier. The public API inventory only supports module re-exports — re-export each name from its source module.`
282
+ )
283
+ );
284
+ return;
285
+ }
286
+ if (!moduleSpecifier.startsWith('.')) {
287
+ ctx.diagnostics.push(
288
+ diagnostic(
289
+ ctx.sourceCode,
290
+ ctx.filePath,
291
+ node.start,
292
+ 'error',
293
+ `${ctx.target.packageName} barrel re-exports from non-relative module specifier '${moduleSpecifier}'. The public API inventory can only resolve relative re-exports to their declarations.`
294
+ )
295
+ );
296
+ return;
297
+ }
298
+ ctx.specifiers.push(
299
+ ...specifiersFromExportDeclaration(node, moduleSpecifier)
300
+ );
301
+ };
302
+
303
+ const inventoryStarExport = (node: AstNode, ctx: InventoryContext): void => {
304
+ if (isTypeKind(node)) {
305
+ return;
306
+ }
307
+ const moduleSpecifier = moduleSpecifierValue(node) ?? '<unknown>';
308
+ ctx.diagnostics.push(
309
+ diagnostic(
310
+ ctx.sourceCode,
311
+ ctx.filePath,
312
+ node.start,
313
+ 'error',
314
+ `${ctx.target.packageName} barrel uses a star re-export from '${moduleSpecifier}'. The public API inventory does not support star re-exports — list each export by name so @example coverage stays checkable.`
315
+ )
316
+ );
317
+ };
318
+
319
+ /**
320
+ * Inventory the non-type-only named re-exports on a target barrel, emitting
321
+ * error diagnostics for shapes the inventory cannot resolve (star
322
+ * re-exports, non-relative specifiers, local export lists).
323
+ */
324
+ const collectBarrelInventory = (
325
+ sourceCode: string,
326
+ filePath: string,
327
+ ast: AstNode,
328
+ target: ResolvedPublicApiTarget
329
+ ): BarrelInventory => {
330
+ const ctx: InventoryContext = {
331
+ diagnostics: [],
332
+ filePath,
333
+ sourceCode,
334
+ specifiers: [],
335
+ target,
336
+ };
337
+ for (const statement of programBody(ast)) {
338
+ if (statement.type === 'ExportNamedDeclaration') {
339
+ inventoryNamedExport(statement, ctx);
340
+ } else if (statement.type === 'ExportAllDeclaration') {
341
+ inventoryStarExport(statement, ctx);
342
+ }
343
+ }
344
+ return { diagnostics: ctx.diagnostics, specifiers: ctx.specifiers };
345
+ };
346
+
347
+ const TS_RE_EXPORT_EXTENSION = /\.js$/;
348
+
349
+ const resolveReexportSourcePath = (
350
+ absoluteIndexPath: string,
351
+ moduleSpecifier: string
352
+ ): string => {
353
+ const withTsExtension = moduleSpecifier.replace(
354
+ TS_RE_EXPORT_EXTENSION,
355
+ '.ts'
356
+ );
357
+ return normalize(join(dirname(absoluteIndexPath), withTsExtension));
358
+ };
359
+
360
+ const declarationNameMatches = (
361
+ declaration: AstNode,
362
+ exportName: string
363
+ ): boolean => {
364
+ if (
365
+ declaration.type === 'FunctionDeclaration' ||
366
+ declaration.type === 'ClassDeclaration' ||
367
+ declaration.type === 'TSInterfaceDeclaration' ||
368
+ declaration.type === 'TSTypeAliasDeclaration'
369
+ ) {
370
+ const id = getNodeId(declaration);
371
+ return readNameNode(id) === exportName;
372
+ }
373
+ if (declaration.type === 'VariableDeclaration') {
374
+ const declarations = getNodeDeclarations(declaration);
375
+ return declarations.some((declarator) => {
376
+ const id = getNodeId(declarator);
377
+ return readNameNode(id) === exportName;
378
+ });
379
+ }
380
+ return false;
381
+ };
382
+
383
+ const EXAMPLE_TAG_PATTERN = /@example\b/;
384
+ const LINE_TERMINATOR_PATTERN = /[\n\r\u2028\u2029]/u;
385
+
386
+ /**
387
+ * True when the exported declaration named `importedName` in `sourceText`
388
+ * carries a leading comment containing `@example`. Parser-native comments in
389
+ * the trivia gap between the preceding top-level statement's end (or file
390
+ * start) and the matching export statement are considered leading.
391
+ */
392
+ const hasLeadingExampleForExport = (
393
+ sourceText: string,
394
+ ast: AstNode,
395
+ comments: readonly SourceComment[],
396
+ importedName: string
397
+ ): boolean => {
398
+ const body = programBody(ast);
399
+ for (const [statementIndex, statement] of body.entries()) {
400
+ if (statement.type !== 'ExportNamedDeclaration') {
401
+ continue;
402
+ }
403
+ const declaration = getNodeDeclaration(statement);
404
+ if (!declaration || !declarationNameMatches(declaration, importedName)) {
405
+ continue;
406
+ }
407
+ const previous = body[statementIndex - 1];
408
+ const gapStart = previous?.end ?? 0;
409
+ return comments.some(
410
+ (comment) =>
411
+ gapStart <= comment.start &&
412
+ comment.end <= statement.start &&
413
+ (previous === undefined ||
414
+ LINE_TERMINATOR_PATTERN.test(
415
+ sourceText.slice(gapStart, comment.start)
416
+ )) &&
417
+ EXAMPLE_TAG_PATTERN.test(comment.value)
418
+ );
419
+ }
420
+ return false;
421
+ };
422
+
423
+ const readSourceFile = (sourcePath: string): string | null => {
424
+ try {
425
+ return readFileSync(sourcePath, 'utf8');
426
+ } catch {
427
+ return null;
428
+ }
429
+ };
430
+
431
+ const coverageDiagnosticsForSpecifier = (
432
+ sourceCode: string,
433
+ filePath: string,
434
+ specifier: PublicExportSpecifier,
435
+ target: ResolvedPublicApiTarget
436
+ ): readonly WardenDiagnostic[] => {
437
+ const sourcePath = resolveReexportSourcePath(
438
+ target.absoluteIndexPath,
439
+ specifier.moduleSpecifier
440
+ );
441
+ const relativeSourcePath = relative(target.rootDir, sourcePath);
442
+ const sourceText = readSourceFile(sourcePath);
443
+ if (sourceText === null) {
444
+ return [
445
+ diagnostic(
446
+ sourceCode,
447
+ filePath,
448
+ specifier.start,
449
+ 'error',
450
+ `${target.packageName} export "${specifier.exportName}" re-exports from unreadable source ${relativeSourcePath}. The public API inventory could not read the resolved module.`
451
+ ),
452
+ ];
453
+ }
454
+ const parsedSource = parseWithDiagnostics(sourcePath, sourceText);
455
+ if (!parsedSource.ast || parsedSource.diagnostics.length > 0) {
456
+ return [
457
+ diagnostic(
458
+ sourceCode,
459
+ filePath,
460
+ specifier.start,
461
+ 'error',
462
+ `${target.packageName} export "${specifier.exportName}" re-exports from unparseable source ${relativeSourcePath}. The public API inventory could not parse the resolved module.`
463
+ ),
464
+ ];
465
+ }
466
+ if (
467
+ hasLeadingExampleForExport(
468
+ sourceText,
469
+ parsedSource.ast,
470
+ parsedSource.comments,
471
+ specifier.importedName
472
+ )
473
+ ) {
474
+ return [];
475
+ }
476
+ const isMinimum = target.minimumExports.includes(specifier.exportName);
477
+ const tier = isMinimum ? 'minimum' : 'inventory';
478
+ return [
479
+ diagnostic(
480
+ sourceCode,
481
+ filePath,
482
+ specifier.start,
483
+ isMinimum ? 'error' : 'warn',
484
+ `${target.packageName} export "${specifier.exportName}" (${tier}) is missing a leading @example TSDoc block on its exported declaration "${specifier.importedName}" in ${relativeSourcePath}. Add an @example to the declaration's TSDoc.`
485
+ ),
486
+ ];
487
+ };
488
+
489
+ const missingMinimumDiagnostics = (
490
+ sourceCode: string,
491
+ filePath: string,
492
+ specifiers: readonly PublicExportSpecifier[],
493
+ target: ResolvedPublicApiTarget
494
+ ): readonly WardenDiagnostic[] => {
495
+ const present = new Set(specifiers.map((specifier) => specifier.exportName));
496
+ return target.minimumExports
497
+ .filter((exportName) => !present.has(exportName))
498
+ .map((exportName) =>
499
+ diagnostic(
500
+ sourceCode,
501
+ filePath,
502
+ 0,
503
+ 'error',
504
+ `${target.packageName} minimum export "${exportName}" is missing from the barrel inventory at ${target.indexPath}. Every minimumExports policy entry must stay re-exported by name on the package barrel.`
505
+ )
506
+ );
507
+ };
508
+
509
+ /**
510
+ * Run the coverage analysis against an explicit resolved-target table.
511
+ * Exported for unit testing so fixtures can anchor to a temp root instead of
512
+ * the real repo barrels. Not part of the public rule API.
513
+ */
514
+ export const checkPublicExportExampleCoverage = (
515
+ sourceCode: string,
516
+ filePath: string,
517
+ targets: readonly ResolvedPublicApiTarget[]
518
+ ): readonly WardenDiagnostic[] => {
519
+ const resolvedPath = resolve(filePath);
520
+ const target = targets.find(
521
+ (candidate) => candidate.absoluteIndexPath === resolvedPath
522
+ );
523
+ if (!target) {
524
+ return [];
525
+ }
526
+ const ast = parse(filePath, sourceCode);
527
+ if (!ast) {
528
+ return [];
529
+ }
530
+ const inventory = collectBarrelInventory(sourceCode, filePath, ast, target);
531
+ return [
532
+ ...inventory.diagnostics,
533
+ ...missingMinimumDiagnostics(
534
+ sourceCode,
535
+ filePath,
536
+ inventory.specifiers,
537
+ target
538
+ ),
539
+ ...inventory.specifiers.flatMap((specifier) =>
540
+ coverageDiagnosticsForSpecifier(sourceCode, filePath, specifier, target)
541
+ ),
542
+ ];
543
+ };
544
+
545
+ /**
546
+ * Warden rule enforcing leading `@example` TSDoc coverage on the public API
547
+ * exports of the v1 surface package barrels (TRL-943).
548
+ */
549
+ export const publicExportExampleCoverage: WardenRule = {
550
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
551
+ return checkPublicExportExampleCoverage(
552
+ sourceCode,
553
+ filePath,
554
+ RESOLVED_TARGETS
555
+ );
556
+ },
557
+ description:
558
+ 'Enforces that public API exports re-exported from the v1 surface package index barrels carry a leading @example TSDoc block, with a mandatory per-package minimumExports coverage list.',
559
+ name: RULE_NAME,
560
+ severity: 'error',
561
+ };