@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.22
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 +508 -6
- package/README.md +77 -26
- package/bin/warden.ts +50 -0
- package/package.json +27 -5
- package/src/adapter-check.ts +136 -0
- package/src/ast.ts +28 -0
- package/src/cli.ts +1374 -103
- package/src/command.ts +953 -0
- package/src/config.ts +184 -0
- package/src/draft.ts +22 -0
- package/src/drift.ts +106 -22
- package/src/fix.ts +120 -0
- package/src/formatters.ts +79 -9
- package/src/guide.ts +245 -0
- package/src/index.ts +206 -14
- package/src/project-context.ts +163 -0
- package/src/resolve.ts +530 -0
- package/src/rules/activation-orphan.ts +97 -0
- package/src/rules/ast.ts +3176 -85
- package/src/rules/circular-refs.ts +154 -0
- package/src/rules/composes-declarations.ts +704 -0
- package/src/rules/context-no-surface-types.ts +68 -8
- package/src/rules/contour-exists.ts +251 -0
- package/src/rules/contour-ids.ts +15 -0
- package/src/rules/dead-internal-trail.ts +154 -0
- package/src/rules/draft-file-marking.ts +160 -0
- package/src/rules/draft-visible-debt.ts +87 -0
- package/src/rules/error-mapping-completeness.ts +288 -0
- package/src/rules/example-valid.ts +401 -0
- package/src/rules/fires-declarations.ts +758 -0
- package/src/rules/implementation-returns-result.ts +1265 -95
- package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
- package/src/rules/incomplete-crud.ts +580 -0
- package/src/rules/index.ts +219 -18
- package/src/rules/intent-propagation.ts +127 -0
- package/src/rules/layer-field-name-drift.ts +96 -0
- package/src/rules/metadata.ts +654 -0
- package/src/rules/missing-reconcile.ts +98 -0
- package/src/rules/missing-visibility.ts +110 -0
- package/src/rules/no-destructured-compose.ts +192 -0
- package/src/rules/no-dev-permit-in-source.ts +99 -0
- package/src/rules/no-direct-implementation-call.ts +7 -7
- package/src/rules/no-legacy-layer-imports.ts +211 -0
- package/src/rules/no-native-error-result.ts +111 -0
- package/src/rules/no-redundant-result-error-wrap.ts +331 -0
- package/src/rules/no-retired-cross-vocabulary.ts +194 -0
- package/src/rules/no-sync-result-assumption.ts +1134 -99
- package/src/rules/no-throw-in-detour-recover.ts +225 -0
- package/src/rules/no-throw-in-implementation.ts +10 -9
- package/src/rules/no-top-level-surface.ts +389 -0
- package/src/rules/on-references-exist.ts +194 -0
- package/src/rules/orphaned-signal.ts +150 -0
- package/src/rules/owner-projection-parity.ts +146 -0
- package/src/rules/permit-governance.ts +25 -0
- package/src/rules/public-export-example-coverage.ts +553 -0
- package/src/rules/public-internal-deep-imports.ts +517 -0
- package/src/rules/public-output-schema.ts +29 -0
- package/src/rules/public-union-output-discriminants.ts +150 -0
- package/src/rules/read-intent-fires.ts +187 -0
- package/src/rules/reference-exists.ts +98 -0
- package/src/rules/registry-names.ts +145 -0
- package/src/rules/resolved-import-boundary.ts +146 -0
- package/src/rules/resource-declarations.ts +704 -0
- package/src/rules/resource-exists.ts +179 -0
- package/src/rules/resource-id-grammar.ts +65 -0
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/scan.ts +38 -25
- package/src/rules/scheduled-destroy-intent.ts +44 -0
- package/src/rules/signal-graph-coaching.ts +191 -0
- package/src/rules/specs.ts +9 -5
- package/src/rules/static-resource-accessor-preference.ts +657 -0
- package/src/rules/surface-facet-coherence.ts +370 -0
- package/src/rules/trail-versioning-source.ts +1094 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/types.ts +270 -6
- package/src/rules/unmaterialized-activation-source.ts +84 -0
- package/src/rules/unreachable-detour-shadowing.ts +344 -0
- package/src/rules/valid-describe-refs.ts +160 -32
- package/src/rules/valid-detour-contract.ts +78 -0
- package/src/rules/warden-export-symmetry.ts +533 -0
- package/src/rules/warden-rules-use-ast.ts +996 -0
- package/src/rules/webhook-route-collision.ts +243 -0
- package/src/trails/activation-orphan.trail.ts +84 -0
- package/src/trails/circular-refs.trail.ts +29 -0
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/contour-exists.trail.ts +21 -0
- package/src/trails/dead-internal-trail.trail.ts +26 -0
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/draft-file-marking.trail.ts +16 -0
- package/src/trails/draft-visible-debt.trail.ts +16 -0
- package/src/trails/error-mapping-completeness.trail.ts +29 -0
- package/src/trails/example-valid.trail.ts +25 -0
- package/src/trails/fires-declarations.trail.ts +23 -0
- package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
- package/src/trails/implementation-returns-result.trail.ts +20 -0
- package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
- package/src/trails/incomplete-crud.trail.ts +39 -0
- package/src/trails/index.ts +78 -0
- package/src/trails/intent-propagation.trail.ts +30 -0
- package/src/trails/layer-field-name-drift.trail.ts +39 -0
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-reconcile.trail.ts +33 -0
- package/src/trails/missing-visibility.trail.ts +22 -0
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
- package/src/trails/no-direct-implementation-call.trail.ts +16 -0
- package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
- package/src/trails/no-native-error-result.trail.ts +18 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
- package/src/trails/no-sync-result-assumption.trail.ts +19 -0
- package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
- package/src/trails/no-throw-in-implementation.trail.ts +20 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/on-references-exist.trail.ts +21 -0
- package/src/trails/orphaned-signal.trail.ts +36 -0
- package/src/trails/owner-projection-parity.trail.ts +26 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/permit-governance.trail.ts +51 -0
- package/src/trails/prefer-schema-inference.trail.ts +21 -0
- package/src/trails/public-export-example-coverage.trail.ts +16 -0
- package/src/trails/public-internal-deep-imports.trail.ts +94 -0
- package/src/trails/public-output-schema.trail.ts +55 -0
- package/src/trails/public-union-output-discriminants.trail.ts +33 -0
- package/src/trails/read-intent-fires.trail.ts +20 -0
- package/src/trails/reference-exists.trail.ts +25 -0
- package/src/trails/resolved-import-boundary.trail.ts +109 -0
- package/src/trails/resource-declarations.trail.ts +25 -0
- package/src/trails/resource-exists.trail.ts +27 -0
- package/src/trails/resource-id-grammar.trail.ts +39 -0
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +162 -0
- package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
- package/src/trails/schema.ts +194 -0
- package/src/trails/signal-graph-coaching.trail.ts +77 -0
- package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
- package/src/trails/surface-facet-coherence.trail.ts +25 -0
- package/src/trails/topo.ts +6 -0
- package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
- package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
- package/src/trails/valid-describe-refs.trail.ts +18 -0
- package/src/trails/valid-detour-contract.trail.ts +71 -0
- package/src/trails/version-gap.trail.ts +35 -0
- package/src/trails/version-pinned-compose.trail.ts +23 -0
- package/src/trails/version-without-examples.trail.ts +38 -0
- package/src/trails/warden-export-symmetry.trail.ts +16 -0
- package/src/trails/warden-rules-use-ast.trail.ts +45 -0
- package/src/trails/webhook-route-collision.trail.ts +50 -0
- package/src/trails/wrap-rule.ts +213 -0
- package/src/workspaces.ts +238 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/cli.d.ts +0 -46
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -221
- package/dist/cli.js.map +0 -1
- package/dist/drift.d.ts +0 -26
- package/dist/drift.d.ts.map +0 -1
- package/dist/drift.js +0 -27
- package/dist/drift.js.map +0 -1
- package/dist/formatters.d.ts +0 -29
- package/dist/formatters.d.ts.map +0 -1
- package/dist/formatters.js +0 -87
- package/dist/formatters.js.map +0 -1
- package/dist/index.d.ts +0 -26
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -26
- package/dist/index.js.map +0 -1
- package/dist/rules/ast.d.ts +0 -41
- package/dist/rules/ast.d.ts.map +0 -1
- package/dist/rules/ast.js +0 -163
- package/dist/rules/ast.js.map +0 -1
- package/dist/rules/context-no-surface-types.d.ts +0 -12
- package/dist/rules/context-no-surface-types.d.ts.map +0 -1
- package/dist/rules/context-no-surface-types.js +0 -96
- package/dist/rules/context-no-surface-types.js.map +0 -1
- package/dist/rules/implementation-returns-result.d.ts +0 -13
- package/dist/rules/implementation-returns-result.d.ts.map +0 -1
- package/dist/rules/implementation-returns-result.js +0 -231
- package/dist/rules/implementation-returns-result.js.map +0 -1
- package/dist/rules/index.d.ts +0 -22
- package/dist/rules/index.d.ts.map +0 -1
- package/dist/rules/index.js +0 -41
- package/dist/rules/index.js.map +0 -1
- package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
- package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
- package/dist/rules/no-direct-impl-in-route.js +0 -46
- package/dist/rules/no-direct-impl-in-route.js.map +0 -1
- package/dist/rules/no-direct-implementation-call.d.ts +0 -12
- package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
- package/dist/rules/no-direct-implementation-call.js +0 -39
- package/dist/rules/no-direct-implementation-call.js.map +0 -1
- package/dist/rules/no-sync-result-assumption.d.ts +0 -6
- package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
- package/dist/rules/no-sync-result-assumption.js +0 -98
- package/dist/rules/no-sync-result-assumption.js.map +0 -1
- package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
- package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
- package/dist/rules/no-throw-in-detour-target.js +0 -87
- package/dist/rules/no-throw-in-detour-target.js.map +0 -1
- package/dist/rules/no-throw-in-implementation.d.ts +0 -9
- package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
- package/dist/rules/no-throw-in-implementation.js +0 -34
- package/dist/rules/no-throw-in-implementation.js.map +0 -1
- package/dist/rules/prefer-schema-inference.d.ts +0 -7
- package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
- package/dist/rules/prefer-schema-inference.js +0 -86
- package/dist/rules/prefer-schema-inference.js.map +0 -1
- package/dist/rules/scan.d.ts +0 -8
- package/dist/rules/scan.d.ts.map +0 -1
- package/dist/rules/scan.js +0 -32
- package/dist/rules/scan.js.map +0 -1
- package/dist/rules/specs.d.ts +0 -29
- package/dist/rules/specs.d.ts.map +0 -1
- package/dist/rules/specs.js +0 -192
- package/dist/rules/specs.js.map +0 -1
- package/dist/rules/structure.d.ts +0 -13
- package/dist/rules/structure.d.ts.map +0 -1
- package/dist/rules/structure.js +0 -142
- package/dist/rules/structure.js.map +0 -1
- package/dist/rules/types.d.ts +0 -52
- package/dist/rules/types.d.ts.map +0 -1
- package/dist/rules/types.js +0 -2
- package/dist/rules/types.js.map +0 -1
- package/dist/rules/valid-describe-refs.d.ts +0 -7
- package/dist/rules/valid-describe-refs.d.ts.map +0 -1
- package/dist/rules/valid-describe-refs.js +0 -51
- package/dist/rules/valid-describe-refs.js.map +0 -1
- package/dist/rules/valid-detour-refs.d.ts +0 -6
- package/dist/rules/valid-detour-refs.d.ts.map +0 -1
- package/dist/rules/valid-detour-refs.js +0 -116
- package/dist/rules/valid-detour-refs.js.map +0 -1
- package/src/__tests__/cli.test.ts +0 -198
- package/src/__tests__/drift.test.ts +0 -74
- package/src/__tests__/formatters.test.ts +0 -157
- package/src/__tests__/implementation-returns-result.test.ts +0 -75
- package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
- package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
- package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
- package/src/__tests__/prefer-schema-inference.test.ts +0 -84
- package/src/__tests__/rules.test.ts +0 -188
- package/src/__tests__/valid-describe-refs.test.ts +0 -60
- package/src/rules/no-direct-impl-in-route.ts +0 -77
- package/src/rules/no-throw-in-detour-target.ts +0 -150
- package/src/rules/valid-detour-refs.ts +0 -187
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,553 @@
|
|
|
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 { offsetToLine, parse } from './ast.js';
|
|
34
|
+
import type { AstNode } from './ast.js';
|
|
35
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
36
|
+
|
|
37
|
+
const RULE_NAME = 'public-export-example-coverage';
|
|
38
|
+
|
|
39
|
+
export interface PublicApiPackageTarget {
|
|
40
|
+
/** Repo-root-relative path to the package's public index barrel. */
|
|
41
|
+
readonly indexPath: string;
|
|
42
|
+
/** Exports that MUST be inventoried and carry `@example` coverage. */
|
|
43
|
+
readonly minimumExports: readonly string[];
|
|
44
|
+
/** Published package name, used in diagnostics. */
|
|
45
|
+
readonly packageName: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Repo-local public API `@example` coverage policy.
|
|
50
|
+
*
|
|
51
|
+
* Ported verbatim from `scripts/check-public-api-examples.ts`
|
|
52
|
+
* (`PUBLIC_API_EXAMPLE_TARGETS`). This table lives in the rule module as
|
|
53
|
+
* repo-local policy: `wardenConfigSchema` is a strict runner-options schema
|
|
54
|
+
* and source-static rules receive only `(sourceCode, filePath)`, so there is
|
|
55
|
+
* no per-rule config channel today. Move the table into Warden config if a
|
|
56
|
+
* per-rule config channel lands.
|
|
57
|
+
*/
|
|
58
|
+
export const PUBLIC_API_EXAMPLE_TARGETS: readonly PublicApiPackageTarget[] = [
|
|
59
|
+
{
|
|
60
|
+
indexPath: 'packages/cli/src/index.ts',
|
|
61
|
+
minimumExports: [
|
|
62
|
+
'deriveCliCommands',
|
|
63
|
+
'deriveFlags',
|
|
64
|
+
'output',
|
|
65
|
+
'deriveOutputMode',
|
|
66
|
+
'findAppModuleCandidates',
|
|
67
|
+
'findAppModule',
|
|
68
|
+
],
|
|
69
|
+
packageName: '@ontrails/cli',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
indexPath: 'packages/http/src/index.ts',
|
|
73
|
+
minimumExports: [
|
|
74
|
+
'deriveHttpRoutes',
|
|
75
|
+
'deriveHttpInputSource',
|
|
76
|
+
'deriveHttpMethod',
|
|
77
|
+
'deriveHttpOperationMethod',
|
|
78
|
+
'deriveOpenApiSpec',
|
|
79
|
+
],
|
|
80
|
+
packageName: '@ontrails/http',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
indexPath: 'packages/mcp/src/index.ts',
|
|
84
|
+
minimumExports: [
|
|
85
|
+
'deriveMcpTools',
|
|
86
|
+
'createServer',
|
|
87
|
+
'surface',
|
|
88
|
+
'connectStdio',
|
|
89
|
+
],
|
|
90
|
+
packageName: '@ontrails/mcp',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
indexPath: 'adapters/commander/src/index.ts',
|
|
94
|
+
minimumExports: ['createProgram', 'surface', 'toCommander'],
|
|
95
|
+
packageName: '@ontrails/commander',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
indexPath: 'adapters/hono/src/index.ts',
|
|
99
|
+
minimumExports: ['createApp', 'surface'],
|
|
100
|
+
packageName: '@ontrails/hono',
|
|
101
|
+
},
|
|
102
|
+
] as const;
|
|
103
|
+
|
|
104
|
+
export interface ResolvedPublicApiTarget extends PublicApiPackageTarget {
|
|
105
|
+
/** Absolute path of the target barrel — the rule's path anchor. */
|
|
106
|
+
readonly absoluteIndexPath: string;
|
|
107
|
+
/** Absolute repo (or fixture) root used to relativize diagnostic paths. */
|
|
108
|
+
readonly rootDir: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve repo-relative policy targets against a root directory. Exported
|
|
113
|
+
* for unit testing — tests build fixture trees under a temp root instead of
|
|
114
|
+
* depending on the real repo barrels. Not part of the public rule API.
|
|
115
|
+
*/
|
|
116
|
+
export const resolvePublicApiExampleTargets = (
|
|
117
|
+
rootDir: string,
|
|
118
|
+
targets: readonly PublicApiPackageTarget[]
|
|
119
|
+
): readonly ResolvedPublicApiTarget[] => {
|
|
120
|
+
const resolvedRoot = resolve(rootDir);
|
|
121
|
+
return targets.map((target) => ({
|
|
122
|
+
...target,
|
|
123
|
+
absoluteIndexPath: resolve(resolvedRoot, target.indexPath),
|
|
124
|
+
rootDir: resolvedRoot,
|
|
125
|
+
}));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Repo root resolved from this rule's own module URL
|
|
130
|
+
* (`packages/warden/src/rules/` → four levels up). Anchoring to the real
|
|
131
|
+
* on-disk location gives the same consumer-repo safety property as
|
|
132
|
+
* `warden-export-symmetry`'s SELF_BARREL_PATH: in a consumer repository the
|
|
133
|
+
* warden package resolves inside `node_modules`, so the computed absolute
|
|
134
|
+
* target paths never match consumer files and the rule stays silent.
|
|
135
|
+
*/
|
|
136
|
+
const REPO_ROOT = resolve(
|
|
137
|
+
fileURLToPath(new URL('../../../..', import.meta.url))
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const RESOLVED_TARGETS = resolvePublicApiExampleTargets(
|
|
141
|
+
REPO_ROOT,
|
|
142
|
+
PUBLIC_API_EXAMPLE_TARGETS
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
interface PublicExportSpecifier {
|
|
146
|
+
/** Public export name as seen on the barrel. */
|
|
147
|
+
readonly exportName: string;
|
|
148
|
+
/** Local source binding name (`propertyName` when aliased). */
|
|
149
|
+
readonly importedName: string;
|
|
150
|
+
readonly moduleSpecifier: string;
|
|
151
|
+
/** Start offset of the export specifier on the barrel. */
|
|
152
|
+
readonly start: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface BarrelInventory {
|
|
156
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
157
|
+
readonly specifiers: readonly PublicExportSpecifier[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isTypeKind = (node: AstNode): boolean =>
|
|
161
|
+
(node as unknown as { exportKind?: string }).exportKind === 'type';
|
|
162
|
+
|
|
163
|
+
const readNameNode = (node: AstNode | undefined): string | null => {
|
|
164
|
+
if (!node) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (node.type === 'Identifier') {
|
|
168
|
+
return (node as unknown as { name?: string }).name ?? null;
|
|
169
|
+
}
|
|
170
|
+
if (node.type === 'Literal' || node.type === 'StringLiteral') {
|
|
171
|
+
const { value } = node as unknown as { value?: unknown };
|
|
172
|
+
return typeof value === 'string' ? value : null;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const moduleSpecifierValue = (node: AstNode): string | null => {
|
|
178
|
+
const { source } = node as unknown as { source?: AstNode };
|
|
179
|
+
if (!source) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const { value } = source as unknown as { value?: unknown };
|
|
183
|
+
return typeof value === 'string' ? value : null;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const programBody = (ast: AstNode): readonly AstNode[] =>
|
|
187
|
+
(ast as unknown as { body?: readonly AstNode[] }).body ?? [];
|
|
188
|
+
|
|
189
|
+
const diagnostic = (
|
|
190
|
+
sourceCode: string,
|
|
191
|
+
filePath: string,
|
|
192
|
+
start: number,
|
|
193
|
+
severity: WardenDiagnostic['severity'],
|
|
194
|
+
message: string
|
|
195
|
+
): WardenDiagnostic => ({
|
|
196
|
+
filePath,
|
|
197
|
+
line: offsetToLine(sourceCode, start),
|
|
198
|
+
message: `${RULE_NAME}: ${message}`,
|
|
199
|
+
rule: RULE_NAME,
|
|
200
|
+
severity,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const specifiersFromExportDeclaration = (
|
|
204
|
+
node: AstNode,
|
|
205
|
+
moduleSpecifier: string
|
|
206
|
+
): readonly PublicExportSpecifier[] => {
|
|
207
|
+
const specifiers =
|
|
208
|
+
(node as unknown as { specifiers?: readonly AstNode[] }).specifiers ?? [];
|
|
209
|
+
return specifiers.flatMap((specifier) => {
|
|
210
|
+
if (specifier.type !== 'ExportSpecifier' || isTypeKind(specifier)) {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
const { exported, local } = specifier as unknown as {
|
|
214
|
+
exported?: AstNode;
|
|
215
|
+
local?: AstNode;
|
|
216
|
+
};
|
|
217
|
+
const exportName = readNameNode(exported);
|
|
218
|
+
if (!exportName) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
return [
|
|
222
|
+
{
|
|
223
|
+
exportName,
|
|
224
|
+
importedName: readNameNode(local) ?? exportName,
|
|
225
|
+
moduleSpecifier,
|
|
226
|
+
start: specifier.start,
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
interface InventoryContext {
|
|
233
|
+
readonly diagnostics: WardenDiagnostic[];
|
|
234
|
+
readonly filePath: string;
|
|
235
|
+
readonly sourceCode: string;
|
|
236
|
+
readonly specifiers: PublicExportSpecifier[];
|
|
237
|
+
readonly target: ResolvedPublicApiTarget;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const inventoryNamedExport = (node: AstNode, ctx: InventoryContext): void => {
|
|
241
|
+
if (isTypeKind(node)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const { declaration } = node as unknown as { declaration?: AstNode };
|
|
245
|
+
if (declaration) {
|
|
246
|
+
// Declaration-form exports (`export const foo = ...`) are not module
|
|
247
|
+
// re-exports; the script's inventory skipped them the same way.
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const moduleSpecifier = moduleSpecifierValue(node);
|
|
251
|
+
if (moduleSpecifier === null) {
|
|
252
|
+
ctx.diagnostics.push(
|
|
253
|
+
diagnostic(
|
|
254
|
+
ctx.sourceCode,
|
|
255
|
+
ctx.filePath,
|
|
256
|
+
node.start,
|
|
257
|
+
'error',
|
|
258
|
+
`${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.`
|
|
259
|
+
)
|
|
260
|
+
);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!moduleSpecifier.startsWith('.')) {
|
|
264
|
+
ctx.diagnostics.push(
|
|
265
|
+
diagnostic(
|
|
266
|
+
ctx.sourceCode,
|
|
267
|
+
ctx.filePath,
|
|
268
|
+
node.start,
|
|
269
|
+
'error',
|
|
270
|
+
`${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.`
|
|
271
|
+
)
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
ctx.specifiers.push(
|
|
276
|
+
...specifiersFromExportDeclaration(node, moduleSpecifier)
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const inventoryStarExport = (node: AstNode, ctx: InventoryContext): void => {
|
|
281
|
+
if (isTypeKind(node)) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const moduleSpecifier = moduleSpecifierValue(node) ?? '<unknown>';
|
|
285
|
+
ctx.diagnostics.push(
|
|
286
|
+
diagnostic(
|
|
287
|
+
ctx.sourceCode,
|
|
288
|
+
ctx.filePath,
|
|
289
|
+
node.start,
|
|
290
|
+
'error',
|
|
291
|
+
`${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.`
|
|
292
|
+
)
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Inventory the non-type-only named re-exports on a target barrel, emitting
|
|
298
|
+
* error diagnostics for shapes the inventory cannot resolve (star
|
|
299
|
+
* re-exports, non-relative specifiers, local export lists).
|
|
300
|
+
*/
|
|
301
|
+
const collectBarrelInventory = (
|
|
302
|
+
sourceCode: string,
|
|
303
|
+
filePath: string,
|
|
304
|
+
ast: AstNode,
|
|
305
|
+
target: ResolvedPublicApiTarget
|
|
306
|
+
): BarrelInventory => {
|
|
307
|
+
const ctx: InventoryContext = {
|
|
308
|
+
diagnostics: [],
|
|
309
|
+
filePath,
|
|
310
|
+
sourceCode,
|
|
311
|
+
specifiers: [],
|
|
312
|
+
target,
|
|
313
|
+
};
|
|
314
|
+
for (const statement of programBody(ast)) {
|
|
315
|
+
if (statement.type === 'ExportNamedDeclaration') {
|
|
316
|
+
inventoryNamedExport(statement, ctx);
|
|
317
|
+
} else if (statement.type === 'ExportAllDeclaration') {
|
|
318
|
+
inventoryStarExport(statement, ctx);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { diagnostics: ctx.diagnostics, specifiers: ctx.specifiers };
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const TS_RE_EXPORT_EXTENSION = /\.js$/;
|
|
325
|
+
|
|
326
|
+
const resolveReexportSourcePath = (
|
|
327
|
+
absoluteIndexPath: string,
|
|
328
|
+
moduleSpecifier: string
|
|
329
|
+
): string => {
|
|
330
|
+
const withTsExtension = moduleSpecifier.replace(
|
|
331
|
+
TS_RE_EXPORT_EXTENSION,
|
|
332
|
+
'.ts'
|
|
333
|
+
);
|
|
334
|
+
return normalize(join(dirname(absoluteIndexPath), withTsExtension));
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const declarationNameMatches = (
|
|
338
|
+
declaration: AstNode,
|
|
339
|
+
exportName: string
|
|
340
|
+
): boolean => {
|
|
341
|
+
if (
|
|
342
|
+
declaration.type === 'FunctionDeclaration' ||
|
|
343
|
+
declaration.type === 'ClassDeclaration' ||
|
|
344
|
+
declaration.type === 'TSInterfaceDeclaration' ||
|
|
345
|
+
declaration.type === 'TSTypeAliasDeclaration'
|
|
346
|
+
) {
|
|
347
|
+
const { id } = declaration as unknown as { id?: AstNode };
|
|
348
|
+
return readNameNode(id) === exportName;
|
|
349
|
+
}
|
|
350
|
+
if (declaration.type === 'VariableDeclaration') {
|
|
351
|
+
const declarations =
|
|
352
|
+
(declaration as unknown as { declarations?: readonly AstNode[] })
|
|
353
|
+
.declarations ?? [];
|
|
354
|
+
return declarations.some((declarator) => {
|
|
355
|
+
const { id } = declarator as unknown as { id?: AstNode };
|
|
356
|
+
return readNameNode(id) === exportName;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Comment texts found in an inter-statement trivia gap. The gap between two
|
|
364
|
+
* top-level statements contains only whitespace and comments, so a small
|
|
365
|
+
* line/block comment scan recovers the same comment set TypeScript's
|
|
366
|
+
* `getLeadingCommentRanges` returns for the statement's full start.
|
|
367
|
+
*/
|
|
368
|
+
const collectCommentTexts = (gapText: string): readonly string[] => {
|
|
369
|
+
const comments: string[] = [];
|
|
370
|
+
let index = 0;
|
|
371
|
+
while (index < gapText.length - 1) {
|
|
372
|
+
if (gapText[index] === '/' && gapText[index + 1] === '/') {
|
|
373
|
+
const lineEnd = gapText.indexOf('\n', index);
|
|
374
|
+
const stop = lineEnd === -1 ? gapText.length : lineEnd;
|
|
375
|
+
comments.push(gapText.slice(index, stop));
|
|
376
|
+
index = stop + 1;
|
|
377
|
+
} else if (gapText[index] === '/' && gapText[index + 1] === '*') {
|
|
378
|
+
const blockEnd = gapText.indexOf('*/', index + 2);
|
|
379
|
+
const stop = blockEnd === -1 ? gapText.length : blockEnd + 2;
|
|
380
|
+
comments.push(gapText.slice(index, stop));
|
|
381
|
+
index = stop;
|
|
382
|
+
} else {
|
|
383
|
+
index += 1;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return comments;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const EXAMPLE_TAG_PATTERN = /@example\b/;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* True when the exported declaration named `importedName` in `sourceText`
|
|
393
|
+
* carries a leading comment containing `@example`. Leading comments are
|
|
394
|
+
* recovered from the trivia gap between the preceding top-level statement's
|
|
395
|
+
* end (or file start) and the matching export statement's start.
|
|
396
|
+
*/
|
|
397
|
+
const hasLeadingExampleForExport = (
|
|
398
|
+
sourceText: string,
|
|
399
|
+
ast: AstNode,
|
|
400
|
+
importedName: string
|
|
401
|
+
): boolean => {
|
|
402
|
+
const body = programBody(ast);
|
|
403
|
+
for (const [statementIndex, statement] of body.entries()) {
|
|
404
|
+
if (statement.type !== 'ExportNamedDeclaration') {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const { declaration } = statement as unknown as { declaration?: AstNode };
|
|
408
|
+
if (!declaration || !declarationNameMatches(declaration, importedName)) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const previous = body[statementIndex - 1];
|
|
412
|
+
const gapText = sourceText.slice(previous?.end ?? 0, statement.start);
|
|
413
|
+
return collectCommentTexts(gapText).some((comment) =>
|
|
414
|
+
EXAMPLE_TAG_PATTERN.test(comment)
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const readSourceFile = (sourcePath: string): string | null => {
|
|
421
|
+
try {
|
|
422
|
+
return readFileSync(sourcePath, 'utf8');
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const coverageDiagnosticsForSpecifier = (
|
|
429
|
+
sourceCode: string,
|
|
430
|
+
filePath: string,
|
|
431
|
+
specifier: PublicExportSpecifier,
|
|
432
|
+
target: ResolvedPublicApiTarget
|
|
433
|
+
): readonly WardenDiagnostic[] => {
|
|
434
|
+
const sourcePath = resolveReexportSourcePath(
|
|
435
|
+
target.absoluteIndexPath,
|
|
436
|
+
specifier.moduleSpecifier
|
|
437
|
+
);
|
|
438
|
+
const relativeSourcePath = relative(target.rootDir, sourcePath);
|
|
439
|
+
const sourceText = readSourceFile(sourcePath);
|
|
440
|
+
if (sourceText === null) {
|
|
441
|
+
return [
|
|
442
|
+
diagnostic(
|
|
443
|
+
sourceCode,
|
|
444
|
+
filePath,
|
|
445
|
+
specifier.start,
|
|
446
|
+
'error',
|
|
447
|
+
`${target.packageName} export "${specifier.exportName}" re-exports from unreadable source ${relativeSourcePath}. The public API inventory could not read the resolved module.`
|
|
448
|
+
),
|
|
449
|
+
];
|
|
450
|
+
}
|
|
451
|
+
const sourceAst = parse(sourcePath, sourceText);
|
|
452
|
+
if (!sourceAst) {
|
|
453
|
+
return [
|
|
454
|
+
diagnostic(
|
|
455
|
+
sourceCode,
|
|
456
|
+
filePath,
|
|
457
|
+
specifier.start,
|
|
458
|
+
'error',
|
|
459
|
+
`${target.packageName} export "${specifier.exportName}" re-exports from unparseable source ${relativeSourcePath}. The public API inventory could not parse the resolved module.`
|
|
460
|
+
),
|
|
461
|
+
];
|
|
462
|
+
}
|
|
463
|
+
if (
|
|
464
|
+
hasLeadingExampleForExport(sourceText, sourceAst, specifier.importedName)
|
|
465
|
+
) {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
const isMinimum = target.minimumExports.includes(specifier.exportName);
|
|
469
|
+
const tier = isMinimum ? 'minimum' : 'inventory';
|
|
470
|
+
return [
|
|
471
|
+
diagnostic(
|
|
472
|
+
sourceCode,
|
|
473
|
+
filePath,
|
|
474
|
+
specifier.start,
|
|
475
|
+
isMinimum ? 'error' : 'warn',
|
|
476
|
+
`${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.`
|
|
477
|
+
),
|
|
478
|
+
];
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const missingMinimumDiagnostics = (
|
|
482
|
+
sourceCode: string,
|
|
483
|
+
filePath: string,
|
|
484
|
+
specifiers: readonly PublicExportSpecifier[],
|
|
485
|
+
target: ResolvedPublicApiTarget
|
|
486
|
+
): readonly WardenDiagnostic[] => {
|
|
487
|
+
const present = new Set(specifiers.map((specifier) => specifier.exportName));
|
|
488
|
+
return target.minimumExports
|
|
489
|
+
.filter((exportName) => !present.has(exportName))
|
|
490
|
+
.map((exportName) =>
|
|
491
|
+
diagnostic(
|
|
492
|
+
sourceCode,
|
|
493
|
+
filePath,
|
|
494
|
+
0,
|
|
495
|
+
'error',
|
|
496
|
+
`${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.`
|
|
497
|
+
)
|
|
498
|
+
);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Run the coverage analysis against an explicit resolved-target table.
|
|
503
|
+
* Exported for unit testing so fixtures can anchor to a temp root instead of
|
|
504
|
+
* the real repo barrels. Not part of the public rule API.
|
|
505
|
+
*/
|
|
506
|
+
export const checkPublicExportExampleCoverage = (
|
|
507
|
+
sourceCode: string,
|
|
508
|
+
filePath: string,
|
|
509
|
+
targets: readonly ResolvedPublicApiTarget[]
|
|
510
|
+
): readonly WardenDiagnostic[] => {
|
|
511
|
+
const resolvedPath = resolve(filePath);
|
|
512
|
+
const target = targets.find(
|
|
513
|
+
(candidate) => candidate.absoluteIndexPath === resolvedPath
|
|
514
|
+
);
|
|
515
|
+
if (!target) {
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
const ast = parse(filePath, sourceCode);
|
|
519
|
+
if (!ast) {
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
const inventory = collectBarrelInventory(sourceCode, filePath, ast, target);
|
|
523
|
+
return [
|
|
524
|
+
...inventory.diagnostics,
|
|
525
|
+
...missingMinimumDiagnostics(
|
|
526
|
+
sourceCode,
|
|
527
|
+
filePath,
|
|
528
|
+
inventory.specifiers,
|
|
529
|
+
target
|
|
530
|
+
),
|
|
531
|
+
...inventory.specifiers.flatMap((specifier) =>
|
|
532
|
+
coverageDiagnosticsForSpecifier(sourceCode, filePath, specifier, target)
|
|
533
|
+
),
|
|
534
|
+
];
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Warden rule enforcing leading `@example` TSDoc coverage on the public API
|
|
539
|
+
* exports of the v1 surface package barrels (TRL-943).
|
|
540
|
+
*/
|
|
541
|
+
export const publicExportExampleCoverage: WardenRule = {
|
|
542
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
543
|
+
return checkPublicExportExampleCoverage(
|
|
544
|
+
sourceCode,
|
|
545
|
+
filePath,
|
|
546
|
+
RESOLVED_TARGETS
|
|
547
|
+
);
|
|
548
|
+
},
|
|
549
|
+
description:
|
|
550
|
+
'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.',
|
|
551
|
+
name: RULE_NAME,
|
|
552
|
+
severity: 'error',
|
|
553
|
+
};
|