@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.21
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 +497 -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,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforces ADR-0036: `@ontrails/warden` exposes only a trail-wrapper + registry
|
|
3
|
+
* surface. Raw rule objects stay internal to `./rules/`. The public barrel
|
|
4
|
+
* (`packages/warden/src/index.ts`) must:
|
|
5
|
+
*
|
|
6
|
+
* 1. Export a matching `*Trail` identifier for every entry in
|
|
7
|
+
* `wardenRules` / `wardenTopoRules`.
|
|
8
|
+
* 2. Not expose a `*Trail` identifier with no matching registry entry.
|
|
9
|
+
* 3. Not re-export a raw rule object by its camelCased name.
|
|
10
|
+
*
|
|
11
|
+
* Properties 1 and 2 cannot be fully derived today because the registry holds
|
|
12
|
+
* raw `WardenRule` objects whose `.check()` methods are called by the trail
|
|
13
|
+
* wrappers; flipping the dependency (registry ← trails) would require unwrapping
|
|
14
|
+
* trails at dispatch time and is out of scope for TRL-341. Enforcement therefore
|
|
15
|
+
* lives as a lint rule keyed on the warden barrel file path.
|
|
16
|
+
*/
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { walk, offsetToLine, parse } from './ast.js';
|
|
20
|
+
import type { AstNode } from './ast.js';
|
|
21
|
+
import { registeredRuleNames } from './registry-names.js';
|
|
22
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
23
|
+
|
|
24
|
+
const SELF_RULE_NAME = 'warden-export-symmetry';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Absolute path to this package's own `src/index.ts`, resolved from the rule's
|
|
28
|
+
* own module URL. Anchoring to the real on-disk location prevents the rule
|
|
29
|
+
* from firing against a foreign `packages/warden/src/index.ts` in a consumer
|
|
30
|
+
* repository with the same folder structure — the rule would otherwise compare
|
|
31
|
+
* that unrelated barrel against `@ontrails/warden`'s internal registry and
|
|
32
|
+
* emit bogus missing/orphan diagnostics that break consumer CI.
|
|
33
|
+
*/
|
|
34
|
+
const SELF_BARREL_PATH = resolve(
|
|
35
|
+
fileURLToPath(new URL('../index.ts', import.meta.url))
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const isTargetFile = (filePath: string): boolean =>
|
|
39
|
+
resolve(filePath) === SELF_BARREL_PATH;
|
|
40
|
+
|
|
41
|
+
const kebabToCamel = (value: string): string =>
|
|
42
|
+
value.replaceAll(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase());
|
|
43
|
+
|
|
44
|
+
interface ExportSite {
|
|
45
|
+
/** Public export name — what consumers see on the barrel. */
|
|
46
|
+
readonly name: string;
|
|
47
|
+
/**
|
|
48
|
+
* Local source binding name. For alias re-exports
|
|
49
|
+
* (`export { foo as bar }`) this is `foo`. Equals `name` for non-aliased
|
|
50
|
+
* exports and for declaration-form exports (`export const foo = ...`).
|
|
51
|
+
* Used by `rawRuleLeakDiagnostics` so aliasing a raw rule does not sanitize it.
|
|
52
|
+
*/
|
|
53
|
+
readonly localName: string;
|
|
54
|
+
readonly start: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const readIdentifierOrStringName = (
|
|
58
|
+
node: AstNode | undefined
|
|
59
|
+
): string | null => {
|
|
60
|
+
if (!node) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (node.type === 'Identifier') {
|
|
64
|
+
return (node as unknown as { name?: string }).name ?? null;
|
|
65
|
+
}
|
|
66
|
+
if (node.type === 'Literal' || node.type === 'StringLiteral') {
|
|
67
|
+
const { value } = node as unknown as { value?: unknown };
|
|
68
|
+
return typeof value === 'string' ? value : null;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const extractSpecifierNames = (
|
|
74
|
+
specifier: AstNode
|
|
75
|
+
): { readonly name: string; readonly localName: string } | null => {
|
|
76
|
+
const { exported, local } = specifier as unknown as {
|
|
77
|
+
exported?: AstNode;
|
|
78
|
+
local?: AstNode;
|
|
79
|
+
};
|
|
80
|
+
const name = readIdentifierOrStringName(exported);
|
|
81
|
+
if (!name) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const localName = readIdentifierOrStringName(local) ?? name;
|
|
85
|
+
return { localName, name };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const isTypeExportSpecifier = (specifier: AstNode): boolean =>
|
|
89
|
+
(specifier as unknown as { exportKind?: string }).exportKind === 'type';
|
|
90
|
+
|
|
91
|
+
const specifierSite = (specifier: AstNode): ExportSite | null => {
|
|
92
|
+
if (
|
|
93
|
+
specifier.type !== 'ExportSpecifier' ||
|
|
94
|
+
isTypeExportSpecifier(specifier)
|
|
95
|
+
) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const names = extractSpecifierNames(specifier);
|
|
99
|
+
return names ? { ...names, start: specifier.start } : null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const TYPE_ONLY_DECL_TYPES = new Set([
|
|
103
|
+
'TSTypeAliasDeclaration',
|
|
104
|
+
'TSInterfaceDeclaration',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const namedSiteFromDeclId = (
|
|
108
|
+
declId: AstNode | undefined,
|
|
109
|
+
start: number
|
|
110
|
+
): ExportSite | null => {
|
|
111
|
+
const name = readIdentifierOrStringName(declId);
|
|
112
|
+
return name ? { localName: name, name, start } : null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract an identifier or `AssignmentPattern`'s left-hand identifier as a
|
|
117
|
+
* single export site. Returns null for anything else (nested patterns should
|
|
118
|
+
* be handled through `sitesFromPattern`).
|
|
119
|
+
*/
|
|
120
|
+
const siteFromSimpleBinding = (
|
|
121
|
+
node: AstNode | undefined,
|
|
122
|
+
start: number
|
|
123
|
+
): ExportSite | null => {
|
|
124
|
+
if (!node) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (node.type === 'Identifier') {
|
|
128
|
+
const name = readIdentifierOrStringName(node);
|
|
129
|
+
return name ? { localName: name, name, start } : null;
|
|
130
|
+
}
|
|
131
|
+
if (node.type === 'AssignmentPattern') {
|
|
132
|
+
const { left } = node as unknown as { left?: AstNode };
|
|
133
|
+
return left ? siteFromSimpleBinding(left, start) : null;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Callback type to break the recursion cycle without use-before-define. */
|
|
139
|
+
type PatternSitesFn = (
|
|
140
|
+
pattern: AstNode | undefined,
|
|
141
|
+
start: number
|
|
142
|
+
) => readonly ExportSite[];
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Compose a rename-pair site from an `ObjectPattern` property's `key` and a
|
|
146
|
+
* resolved value site. Rename pairs (`{ foo: bar }`) emit one site whose
|
|
147
|
+
* `localName` is the source binding `foo` and whose public `name` is the
|
|
148
|
+
* target `bar`, mirroring `extractSpecifierNames` for `export { foo as bar }`.
|
|
149
|
+
*/
|
|
150
|
+
const renamePairSite = (
|
|
151
|
+
key: AstNode | undefined,
|
|
152
|
+
valueSite: ExportSite,
|
|
153
|
+
start: number
|
|
154
|
+
): ExportSite => {
|
|
155
|
+
const keyName = readIdentifierOrStringName(key);
|
|
156
|
+
return {
|
|
157
|
+
localName: keyName ?? valueSite.localName,
|
|
158
|
+
name: valueSite.name,
|
|
159
|
+
start,
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const isNestedPatternValue = (value: AstNode | undefined): boolean =>
|
|
164
|
+
!!value && value.type !== 'Identifier' && value.type !== 'AssignmentPattern';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Extract sites from a single `ObjectPattern` property.
|
|
168
|
+
*/
|
|
169
|
+
const sitesFromObjectProperty = (
|
|
170
|
+
prop: AstNode,
|
|
171
|
+
start: number,
|
|
172
|
+
recurse: PatternSitesFn
|
|
173
|
+
): readonly ExportSite[] => {
|
|
174
|
+
if (prop.type === 'RestElement') {
|
|
175
|
+
const { argument } = prop as unknown as { argument?: AstNode };
|
|
176
|
+
return recurse(argument, start);
|
|
177
|
+
}
|
|
178
|
+
if (prop.type !== 'Property') {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
const { key, value } = prop as unknown as {
|
|
182
|
+
key?: AstNode;
|
|
183
|
+
value?: AstNode;
|
|
184
|
+
};
|
|
185
|
+
if (isNestedPatternValue(value)) {
|
|
186
|
+
return recurse(value, start);
|
|
187
|
+
}
|
|
188
|
+
const valueSite = siteFromSimpleBinding(value, start);
|
|
189
|
+
return valueSite ? [renamePairSite(key, valueSite, start)] : [];
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const sitesFromArrayElement = (
|
|
193
|
+
element: AstNode | null,
|
|
194
|
+
start: number,
|
|
195
|
+
recurse: PatternSitesFn
|
|
196
|
+
): readonly ExportSite[] => {
|
|
197
|
+
if (!element) {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
if (element.type === 'RestElement') {
|
|
201
|
+
const { argument } = element as unknown as { argument?: AstNode };
|
|
202
|
+
return recurse(argument, start);
|
|
203
|
+
}
|
|
204
|
+
return recurse(element, start);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const sitesFromObjectPattern = (
|
|
208
|
+
pattern: AstNode,
|
|
209
|
+
start: number,
|
|
210
|
+
recurse: PatternSitesFn
|
|
211
|
+
): readonly ExportSite[] => {
|
|
212
|
+
const properties =
|
|
213
|
+
(pattern as unknown as { properties?: readonly AstNode[] }).properties ??
|
|
214
|
+
[];
|
|
215
|
+
return properties.flatMap((prop) =>
|
|
216
|
+
sitesFromObjectProperty(prop, start, recurse)
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const sitesFromArrayPattern = (
|
|
221
|
+
pattern: AstNode,
|
|
222
|
+
start: number,
|
|
223
|
+
recurse: PatternSitesFn
|
|
224
|
+
): readonly ExportSite[] => {
|
|
225
|
+
const elements =
|
|
226
|
+
(pattern as unknown as { elements?: readonly (AstNode | null)[] })
|
|
227
|
+
.elements ?? [];
|
|
228
|
+
return elements.flatMap((element) =>
|
|
229
|
+
sitesFromArrayElement(element, start, recurse)
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Recursively extract export sites from a declarator id, supporting
|
|
235
|
+
* `ObjectPattern` and `ArrayPattern` destructuring. Without this, a
|
|
236
|
+
* destructured `export const { wardenExportSymmetry } = rulesModule` silently
|
|
237
|
+
* bypasses orphan-trail and raw-rule-leak checks because the id is not an
|
|
238
|
+
* `Identifier`.
|
|
239
|
+
*/
|
|
240
|
+
const sitesFromPattern: PatternSitesFn = (pattern, start) => {
|
|
241
|
+
if (!pattern) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
const simple = siteFromSimpleBinding(pattern, start);
|
|
245
|
+
if (simple) {
|
|
246
|
+
return [simple];
|
|
247
|
+
}
|
|
248
|
+
if (pattern.type === 'ObjectPattern') {
|
|
249
|
+
return sitesFromObjectPattern(pattern, start, sitesFromPattern);
|
|
250
|
+
}
|
|
251
|
+
if (pattern.type === 'ArrayPattern') {
|
|
252
|
+
return sitesFromArrayPattern(pattern, start, sitesFromPattern);
|
|
253
|
+
}
|
|
254
|
+
return [];
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const sitesForDeclaration = (declaration: AstNode): readonly ExportSite[] => {
|
|
258
|
+
if (TYPE_ONLY_DECL_TYPES.has(declaration.type)) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
declaration.type === 'FunctionDeclaration' ||
|
|
263
|
+
declaration.type === 'ClassDeclaration'
|
|
264
|
+
) {
|
|
265
|
+
const { id } = declaration as unknown as { id?: AstNode };
|
|
266
|
+
const site = namedSiteFromDeclId(id, declaration.start);
|
|
267
|
+
return site ? [site] : [];
|
|
268
|
+
}
|
|
269
|
+
if (declaration.type === 'VariableDeclaration') {
|
|
270
|
+
const declarations =
|
|
271
|
+
(declaration as unknown as { declarations?: readonly AstNode[] })
|
|
272
|
+
.declarations ?? [];
|
|
273
|
+
return declarations.flatMap((declarator) => {
|
|
274
|
+
const { id } = declarator as unknown as { id?: AstNode };
|
|
275
|
+
return sitesFromPattern(id, declarator.start);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return [];
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const sitesForExportNode = (node: AstNode): readonly ExportSite[] => {
|
|
282
|
+
if (node.type !== 'ExportNamedDeclaration') {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
if ((node as unknown as { exportKind?: string }).exportKind === 'type') {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
const { declaration } = node as unknown as { declaration?: AstNode };
|
|
289
|
+
if (declaration) {
|
|
290
|
+
return sitesForDeclaration(declaration);
|
|
291
|
+
}
|
|
292
|
+
const specifiers =
|
|
293
|
+
(node['specifiers'] as readonly AstNode[] | undefined) ?? [];
|
|
294
|
+
return specifiers.flatMap((specifier) => {
|
|
295
|
+
const site = specifierSite(specifier);
|
|
296
|
+
return site ? [site] : [];
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const collectNamedExports = (ast: AstNode): readonly ExportSite[] => {
|
|
301
|
+
const sites: ExportSite[] = [];
|
|
302
|
+
walk(ast, (node) => {
|
|
303
|
+
sites.push(...sitesForExportNode(node));
|
|
304
|
+
});
|
|
305
|
+
return sites;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
interface NamespaceReexportSite {
|
|
309
|
+
/** Source module path, e.g. `'./trails/index.js'`. */
|
|
310
|
+
readonly target: string;
|
|
311
|
+
/** Alias for `export * as <alias> from '...'`, null for bare `export *`. */
|
|
312
|
+
readonly alias: string | null;
|
|
313
|
+
readonly start: number;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const collectNamespaceReexports = (
|
|
317
|
+
ast: AstNode
|
|
318
|
+
): readonly NamespaceReexportSite[] => {
|
|
319
|
+
const sites: NamespaceReexportSite[] = [];
|
|
320
|
+
walk(ast, (node) => {
|
|
321
|
+
if (node.type !== 'ExportAllDeclaration') {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Mirror the `ExportNamedDeclaration` guard: `export type * from ...` and
|
|
325
|
+
// `export type * as ns from ...` propagate types only, never runtime
|
|
326
|
+
// identifiers, so they cannot leak raw rule objects and must be allowed.
|
|
327
|
+
if ((node as unknown as { exportKind?: string }).exportKind === 'type') {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const { source, exported } = node as unknown as {
|
|
331
|
+
source?: { value?: unknown };
|
|
332
|
+
exported?: AstNode;
|
|
333
|
+
};
|
|
334
|
+
const target =
|
|
335
|
+
typeof source?.value === 'string' ? source.value : '<unknown>';
|
|
336
|
+
// `export * as <alias> from '...'` exposes the alias as an
|
|
337
|
+
// `IdentifierName` / string-literal node on `exported`. Bare `export *`
|
|
338
|
+
// has `exported === null`.
|
|
339
|
+
const alias = readIdentifierOrStringName(exported);
|
|
340
|
+
sites.push({ alias, start: node.start, target });
|
|
341
|
+
});
|
|
342
|
+
return sites;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const formatNamespaceReexport = (site: NamespaceReexportSite): string =>
|
|
346
|
+
site.alias
|
|
347
|
+
? `* as ${site.alias} from '${site.target}'`
|
|
348
|
+
: `* from '${site.target}'`;
|
|
349
|
+
|
|
350
|
+
const namespaceReexportDiagnostics = (
|
|
351
|
+
sourceCode: string,
|
|
352
|
+
filePath: string,
|
|
353
|
+
sites: readonly NamespaceReexportSite[]
|
|
354
|
+
): readonly WardenDiagnostic[] =>
|
|
355
|
+
sites.map((site) => ({
|
|
356
|
+
filePath,
|
|
357
|
+
line: offsetToLine(sourceCode, site.start),
|
|
358
|
+
message:
|
|
359
|
+
`warden-export-symmetry: namespace re-export "export ${formatNamespaceReexport(site)}" is not permitted on the warden public barrel. ` +
|
|
360
|
+
'The rule cannot verify registry ↔ trail symmetry through a star export — list each *Trail by name instead (ADR-0036).',
|
|
361
|
+
rule: 'warden-export-symmetry',
|
|
362
|
+
severity: 'error' as const,
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
const buildRegistryNameSets = (): {
|
|
366
|
+
readonly ruleNames: readonly string[];
|
|
367
|
+
readonly expectedTrailExports: ReadonlySet<string>;
|
|
368
|
+
readonly rawRuleCamelNames: ReadonlySet<string>;
|
|
369
|
+
} => {
|
|
370
|
+
const ruleNames = [...registeredRuleNames, SELF_RULE_NAME];
|
|
371
|
+
const camelNames = ruleNames.map(kebabToCamel);
|
|
372
|
+
return {
|
|
373
|
+
expectedTrailExports: new Set(camelNames.map((name) => `${name}Trail`)),
|
|
374
|
+
rawRuleCamelNames: new Set(camelNames),
|
|
375
|
+
ruleNames,
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const missingTrailDiagnostics = (
|
|
380
|
+
filePath: string,
|
|
381
|
+
expected: ReadonlySet<string>,
|
|
382
|
+
present: ReadonlySet<string>
|
|
383
|
+
): readonly WardenDiagnostic[] =>
|
|
384
|
+
[...expected]
|
|
385
|
+
.filter((name) => !present.has(name))
|
|
386
|
+
.map((name) => ({
|
|
387
|
+
filePath,
|
|
388
|
+
line: 1,
|
|
389
|
+
message:
|
|
390
|
+
`warden-export-symmetry: missing trail export "${name}" for registered warden rule. ` +
|
|
391
|
+
'Every wardenRules / wardenTopoRules entry must have a matching *Trail export on the public barrel (ADR-0036).',
|
|
392
|
+
rule: 'warden-export-symmetry',
|
|
393
|
+
severity: 'error' as const,
|
|
394
|
+
}));
|
|
395
|
+
|
|
396
|
+
const orphanTrailDiagnostics = (
|
|
397
|
+
sourceCode: string,
|
|
398
|
+
filePath: string,
|
|
399
|
+
exports: readonly ExportSite[],
|
|
400
|
+
expected: ReadonlySet<string>
|
|
401
|
+
): readonly WardenDiagnostic[] =>
|
|
402
|
+
exports
|
|
403
|
+
.filter((site) => site.name.endsWith('Trail') && !expected.has(site.name))
|
|
404
|
+
.map((site) => ({
|
|
405
|
+
filePath,
|
|
406
|
+
line: offsetToLine(sourceCode, site.start),
|
|
407
|
+
message:
|
|
408
|
+
`warden-export-symmetry: orphan trail export "${site.name}" has no matching wardenRules / wardenTopoRules entry. ` +
|
|
409
|
+
'Remove the export or register the corresponding rule (ADR-0036).',
|
|
410
|
+
rule: 'warden-export-symmetry',
|
|
411
|
+
severity: 'error' as const,
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
const pickRawRuleMatch = (
|
|
415
|
+
site: ExportSite,
|
|
416
|
+
rawNames: ReadonlySet<string>
|
|
417
|
+
): string | null => {
|
|
418
|
+
if (rawNames.has(site.localName)) {
|
|
419
|
+
return site.localName;
|
|
420
|
+
}
|
|
421
|
+
if (rawNames.has(site.name)) {
|
|
422
|
+
return site.name;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const rawRuleLeakDiagnostics = (
|
|
428
|
+
sourceCode: string,
|
|
429
|
+
filePath: string,
|
|
430
|
+
exports: readonly ExportSite[],
|
|
431
|
+
rawNames: ReadonlySet<string>
|
|
432
|
+
): readonly WardenDiagnostic[] =>
|
|
433
|
+
exports.flatMap((site) => {
|
|
434
|
+
// Check BOTH the public name and the local source binding — aliasing a
|
|
435
|
+
// raw rule (`export { wardenExportSymmetry as disguised }`) must not
|
|
436
|
+
// sanitize the leak. Prefer the raw-matching name in the diagnostic so
|
|
437
|
+
// the incident points at the actual rule identifier.
|
|
438
|
+
const matched = pickRawRuleMatch(site, rawNames);
|
|
439
|
+
if (!matched) {
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
const alias =
|
|
443
|
+
site.localName === site.name ? '' : ` (aliased as "${site.name}")`;
|
|
444
|
+
return [
|
|
445
|
+
{
|
|
446
|
+
filePath,
|
|
447
|
+
line: offsetToLine(sourceCode, site.start),
|
|
448
|
+
message:
|
|
449
|
+
`warden-export-symmetry: raw rule export "${matched}"${alias} must not appear on the public barrel. ` +
|
|
450
|
+
'Raw WardenRule objects are internal; expose the matching *Trail wrapper instead (ADR-0036).',
|
|
451
|
+
rule: 'warden-export-symmetry',
|
|
452
|
+
severity: 'error' as const,
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const collectDefaultExports = (ast: AstNode): readonly ExportSite[] => {
|
|
458
|
+
const sites: ExportSite[] = [];
|
|
459
|
+
walk(ast, (node) => {
|
|
460
|
+
if (node.type !== 'ExportDefaultDeclaration') {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
sites.push({ localName: 'default', name: 'default', start: node.start });
|
|
464
|
+
});
|
|
465
|
+
return sites;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const defaultExportDiagnostics = (
|
|
469
|
+
sourceCode: string,
|
|
470
|
+
filePath: string,
|
|
471
|
+
sites: readonly ExportSite[]
|
|
472
|
+
): readonly WardenDiagnostic[] =>
|
|
473
|
+
sites.map((site) => ({
|
|
474
|
+
filePath,
|
|
475
|
+
line: offsetToLine(sourceCode, site.start),
|
|
476
|
+
message:
|
|
477
|
+
'warden-export-symmetry: default export is not permitted on the warden public barrel. ' +
|
|
478
|
+
'Use named exports only so registry ↔ trail symmetry is discoverable (ADR-0036).',
|
|
479
|
+
rule: 'warden-export-symmetry',
|
|
480
|
+
severity: 'error' as const,
|
|
481
|
+
}));
|
|
482
|
+
|
|
483
|
+
const analyzeBarrel = (
|
|
484
|
+
sourceCode: string,
|
|
485
|
+
filePath: string,
|
|
486
|
+
ast: AstNode
|
|
487
|
+
): readonly WardenDiagnostic[] => {
|
|
488
|
+
const exports = collectNamedExports(ast);
|
|
489
|
+
const presentExports = new Set(exports.map((site) => site.name));
|
|
490
|
+
const { expectedTrailExports, rawRuleCamelNames } = buildRegistryNameSets();
|
|
491
|
+
|
|
492
|
+
return [
|
|
493
|
+
...namespaceReexportDiagnostics(
|
|
494
|
+
sourceCode,
|
|
495
|
+
filePath,
|
|
496
|
+
collectNamespaceReexports(ast)
|
|
497
|
+
),
|
|
498
|
+
...defaultExportDiagnostics(
|
|
499
|
+
sourceCode,
|
|
500
|
+
filePath,
|
|
501
|
+
collectDefaultExports(ast)
|
|
502
|
+
),
|
|
503
|
+
...missingTrailDiagnostics(filePath, expectedTrailExports, presentExports),
|
|
504
|
+
...orphanTrailDiagnostics(
|
|
505
|
+
sourceCode,
|
|
506
|
+
filePath,
|
|
507
|
+
exports,
|
|
508
|
+
expectedTrailExports
|
|
509
|
+
),
|
|
510
|
+
...rawRuleLeakDiagnostics(sourceCode, filePath, exports, rawRuleCamelNames),
|
|
511
|
+
];
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Warden rule enforcing ADR-0036 registry ↔ trail export symmetry on the
|
|
516
|
+
* `@ontrails/warden` public barrel.
|
|
517
|
+
*/
|
|
518
|
+
export const wardenExportSymmetry: WardenRule = {
|
|
519
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
520
|
+
if (!isTargetFile(filePath)) {
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
const ast = parse(filePath, sourceCode);
|
|
524
|
+
if (!ast) {
|
|
525
|
+
return [];
|
|
526
|
+
}
|
|
527
|
+
return analyzeBarrel(sourceCode, filePath, ast);
|
|
528
|
+
},
|
|
529
|
+
description:
|
|
530
|
+
'Enforces ADR-0036: every wardenRules / wardenTopoRules entry has a matching *Trail export, no orphan *Trail exports, and no raw rule objects leak onto the @ontrails/warden public barrel.',
|
|
531
|
+
name: 'warden-export-symmetry',
|
|
532
|
+
severity: 'error',
|
|
533
|
+
};
|