@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
package/src/rules/ast.ts
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
* Shared AST utilities for warden rules.
|
|
3
3
|
*
|
|
4
4
|
* Uses oxc-parser for native-speed TypeScript parsing. Provides a lightweight
|
|
5
|
-
* walker and helpers for finding
|
|
5
|
+
* walker and helpers for finding blaze bodies.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
import { DRAFT_ID_PREFIX, intentValues } from '@ontrails/core';
|
|
13
|
+
import type { Intent } from '@ontrails/core';
|
|
8
14
|
import { parseSync } from 'oxc-parser';
|
|
9
15
|
|
|
10
16
|
// ---------------------------------------------------------------------------
|
|
@@ -16,11 +22,19 @@ export interface AstNode {
|
|
|
16
22
|
readonly start: number;
|
|
17
23
|
readonly end: number;
|
|
18
24
|
readonly key?: { readonly name?: string };
|
|
19
|
-
readonly value?:
|
|
25
|
+
readonly value?: unknown;
|
|
20
26
|
readonly body?: AstNode | readonly AstNode[];
|
|
21
27
|
readonly [key: string]: unknown;
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
interface StringLiteralNode extends AstNode {
|
|
31
|
+
readonly type: 'Literal' | 'StringLiteral';
|
|
32
|
+
readonly value?: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isAstNode = (value: unknown): value is AstNode =>
|
|
36
|
+
Boolean(value && typeof value === 'object' && (value as AstNode).type);
|
|
37
|
+
|
|
24
38
|
// ---------------------------------------------------------------------------
|
|
25
39
|
// Parser
|
|
26
40
|
// ---------------------------------------------------------------------------
|
|
@@ -39,8 +53,26 @@ export const parse = (filePath: string, sourceCode: string): AstNode | null => {
|
|
|
39
53
|
// Walker
|
|
40
54
|
// ---------------------------------------------------------------------------
|
|
41
55
|
|
|
56
|
+
type WalkFn = (node: unknown, visit: (node: AstNode) => void) => void;
|
|
57
|
+
|
|
58
|
+
const walkChildren = (
|
|
59
|
+
node: AstNode,
|
|
60
|
+
visit: (node: AstNode) => void,
|
|
61
|
+
recurse: WalkFn
|
|
62
|
+
): void => {
|
|
63
|
+
for (const val of Object.values(node)) {
|
|
64
|
+
if (Array.isArray(val)) {
|
|
65
|
+
for (const item of val) {
|
|
66
|
+
recurse(item, visit);
|
|
67
|
+
}
|
|
68
|
+
} else if (val && typeof val === 'object' && (val as AstNode).type) {
|
|
69
|
+
recurse(val, visit);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
42
74
|
/** Walk an AST node tree, calling `visit` on every node. */
|
|
43
|
-
export const walk = (node
|
|
75
|
+
export const walk: WalkFn = (node, visit) => {
|
|
44
76
|
if (!node || typeof node !== 'object') {
|
|
45
77
|
return;
|
|
46
78
|
}
|
|
@@ -48,15 +80,44 @@ export const walk = (node: unknown, visit: (node: AstNode) => void): void => {
|
|
|
48
80
|
if (n.type) {
|
|
49
81
|
visit(n);
|
|
50
82
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
walkChildren(n, visit, walk);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const NESTED_SCOPE_TYPES = new Set([
|
|
87
|
+
'ArrowFunctionExpression',
|
|
88
|
+
'FunctionExpression',
|
|
89
|
+
'FunctionDeclaration',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const walkScopeInner: WalkFn = (node, visit) => {
|
|
93
|
+
if (!node || typeof node !== 'object') {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const n = node as AstNode;
|
|
97
|
+
if (n.type) {
|
|
98
|
+
visit(n);
|
|
99
|
+
if (NESTED_SCOPE_TYPES.has(n.type)) {
|
|
100
|
+
return;
|
|
58
101
|
}
|
|
59
102
|
}
|
|
103
|
+
walkChildren(n, visit, walkScopeInner);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Walk an AST node tree without descending into nested function scopes.
|
|
108
|
+
* The root node is always traversed; only inner function boundaries are skipped.
|
|
109
|
+
* Useful for resource-access analysis where inner functions may shadow
|
|
110
|
+
* the trail context parameter name.
|
|
111
|
+
*/
|
|
112
|
+
export const walkScope: WalkFn = (node, visit) => {
|
|
113
|
+
if (!node || typeof node !== 'object') {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const n = node as AstNode;
|
|
117
|
+
if (n.type) {
|
|
118
|
+
visit(n);
|
|
119
|
+
}
|
|
120
|
+
walkChildren(n, visit, walkScopeInner);
|
|
60
121
|
};
|
|
61
122
|
|
|
62
123
|
// ---------------------------------------------------------------------------
|
|
@@ -74,128 +135,446 @@ export const offsetToLine = (sourceCode: string, offset: number): number => {
|
|
|
74
135
|
return line;
|
|
75
136
|
};
|
|
76
137
|
|
|
77
|
-
/**
|
|
78
|
-
export const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
node.key?.name === 'implementation' &&
|
|
84
|
-
node.value
|
|
85
|
-
) {
|
|
86
|
-
bodies.push(node.value);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
return bodies;
|
|
138
|
+
/** Get the name of an Identifier node, or null. */
|
|
139
|
+
export const identifierName = (node: AstNode | undefined): string | null => {
|
|
140
|
+
if (node?.type !== 'Identifier') {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return (node as unknown as { name?: string }).name ?? null;
|
|
90
144
|
};
|
|
91
145
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
146
|
+
/** Check if a node is a string literal. */
|
|
147
|
+
export const isStringLiteral = (
|
|
148
|
+
node: AstNode | undefined
|
|
149
|
+
): node is StringLiteralNode => {
|
|
150
|
+
if (!node) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (node.type === 'StringLiteral') {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
if (node.type === 'Literal') {
|
|
157
|
+
return typeof (node as unknown as { value?: unknown }).value === 'string';
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Extract the string value from a string literal node. */
|
|
163
|
+
export const getStringValue = (node: AstNode): string | null => {
|
|
164
|
+
const val = (node as unknown as { value?: unknown }).value;
|
|
165
|
+
return typeof val === 'string' ? val : null;
|
|
166
|
+
};
|
|
102
167
|
|
|
103
168
|
/**
|
|
104
|
-
*
|
|
169
|
+
* Best-effort resolution of `const NAME = 'value'` declarations via regex.
|
|
105
170
|
*
|
|
106
|
-
* Returns the
|
|
171
|
+
* Returns the string value if a simple `const <name> = '...'` or `"..."` is
|
|
172
|
+
* found in the source. Returns null for anything more complex. Shared between
|
|
173
|
+
* warden rules that need to resolve identifier references to signal / trail
|
|
174
|
+
* IDs at lint time.
|
|
107
175
|
*/
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
176
|
+
export const deriveConstString = (
|
|
177
|
+
name: string,
|
|
178
|
+
sourceCode: string
|
|
179
|
+
): string | null => {
|
|
180
|
+
const pattern = new RegExp(
|
|
181
|
+
`const\\s+${name}\\s*=\\s*(?:'([^']*)'|"([^"]*)")`
|
|
182
|
+
);
|
|
183
|
+
const match = pattern.exec(sourceCode);
|
|
184
|
+
if (!match) {
|
|
112
185
|
return null;
|
|
113
186
|
}
|
|
114
|
-
|
|
115
|
-
|
|
187
|
+
return match[1] ?? match[2] ?? null;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Extract a string literal value, or null when the node is not a string. */
|
|
191
|
+
export const extractStringLiteral = (
|
|
192
|
+
node: AstNode | undefined
|
|
193
|
+
): string | null =>
|
|
194
|
+
node && isStringLiteral(node) ? getStringValue(node) : null;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Extract the cooked value from a `TemplateLiteral` with no interpolations
|
|
198
|
+
* (e.g. `` `entity.fallback` ``). Template literals with `${...}` expressions
|
|
199
|
+
* cannot be resolved at lint time and return null.
|
|
200
|
+
*
|
|
201
|
+
* Shared helper used by rules that accept both string literals and simple
|
|
202
|
+
* backtick-literal IDs (e.g. `valid-describe-refs`).
|
|
203
|
+
*/
|
|
204
|
+
const getSingleQuasi = (node: AstNode): AstNode | null => {
|
|
205
|
+
const expressions =
|
|
206
|
+
(node['expressions'] as readonly AstNode[] | undefined) ?? [];
|
|
207
|
+
if (expressions.length > 0) {
|
|
116
208
|
return null;
|
|
117
209
|
}
|
|
118
|
-
const
|
|
119
|
-
return
|
|
210
|
+
const quasis = (node['quasis'] as readonly AstNode[] | undefined) ?? [];
|
|
211
|
+
return quasis.length === 1 ? (quasis[0] ?? null) : null;
|
|
120
212
|
};
|
|
121
213
|
|
|
122
|
-
const
|
|
123
|
-
node: AstNode
|
|
124
|
-
):
|
|
125
|
-
|
|
126
|
-
if (!args || args.length < 2) {
|
|
214
|
+
export const extractPlainTemplateLiteral = (
|
|
215
|
+
node: AstNode | undefined
|
|
216
|
+
): string | null => {
|
|
217
|
+
if (!node || node.type !== 'TemplateLiteral') {
|
|
127
218
|
return null;
|
|
128
219
|
}
|
|
129
|
-
const
|
|
130
|
-
if (!
|
|
220
|
+
const quasi = getSingleQuasi(node);
|
|
221
|
+
if (!quasi) {
|
|
131
222
|
return null;
|
|
132
223
|
}
|
|
133
|
-
|
|
224
|
+
const cooked = (quasi as unknown as { value?: { cooked?: unknown } }).value
|
|
225
|
+
?.cooked;
|
|
226
|
+
return typeof cooked === 'string' ? cooked : null;
|
|
134
227
|
};
|
|
135
228
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Extract a string value from either a string literal or a plain template
|
|
231
|
+
* literal (no `${...}` expressions). Returns null for anything else.
|
|
232
|
+
*/
|
|
233
|
+
export const extractStringOrTemplateLiteral = (
|
|
234
|
+
node: AstNode | undefined
|
|
235
|
+
): string | null =>
|
|
236
|
+
extractStringLiteral(node) ?? extractPlainTemplateLiteral(node);
|
|
237
|
+
|
|
238
|
+
export interface StringLiteralMatch {
|
|
239
|
+
readonly end: number;
|
|
240
|
+
readonly node: AstNode;
|
|
241
|
+
readonly start: number;
|
|
242
|
+
readonly value: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Names of framework constants whose value is a draft-marker prefix literal.
|
|
247
|
+
*
|
|
248
|
+
* String literals that initialize a `const` declaration with one of these
|
|
249
|
+
* names are treated as the framework's own draft-marker declarations, not as
|
|
250
|
+
* draft-id usage. This list is intentionally small and explicit — adding a
|
|
251
|
+
* new framework draft-prefix constant requires updating this set.
|
|
252
|
+
*/
|
|
253
|
+
export const FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES: ReadonlySet<string> =
|
|
254
|
+
new Set(['DRAFT_ID_PREFIX', 'DRAFT_FILE_PREFIX']);
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Exact string literal value allowed for framework draft-prefix constant
|
|
258
|
+
* declarations. Tightens the exemption so a future framework file cannot
|
|
259
|
+
* redeclare `DRAFT_ID_PREFIX = '_draft.something-else'` and accidentally
|
|
260
|
+
* suppress its own draft-id diagnostic.
|
|
261
|
+
*/
|
|
262
|
+
const FRAMEWORK_DRAFT_PREFIX_LITERAL = DRAFT_ID_PREFIX;
|
|
263
|
+
|
|
264
|
+
interface PackageJsonWithName {
|
|
265
|
+
readonly name: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const FRAMEWORK_DRAFT_PREFIX_PACKAGES: ReadonlySet<string> = new Set([
|
|
269
|
+
'@ontrails/core',
|
|
270
|
+
'@ontrails/warden',
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
const isPackageJsonWithName = (value: unknown): value is PackageJsonWithName =>
|
|
274
|
+
typeof value === 'object' &&
|
|
275
|
+
value !== null &&
|
|
276
|
+
typeof (value as { name?: unknown }).name === 'string';
|
|
277
|
+
|
|
278
|
+
const readPackageJsonName = (packageJsonPath: string): string | null => {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
281
|
+
return isPackageJsonWithName(parsed) ? parsed.name : null;
|
|
282
|
+
} catch {
|
|
139
283
|
return null;
|
|
140
284
|
}
|
|
285
|
+
};
|
|
141
286
|
|
|
142
|
-
|
|
143
|
-
|
|
287
|
+
const frameworkDraftPackageRoot = (filePath: string): string | null => {
|
|
288
|
+
const resolvedPath = resolve(filePath);
|
|
289
|
+
if (basename(resolvedPath) !== 'draft.ts') {
|
|
144
290
|
return null;
|
|
145
291
|
}
|
|
146
292
|
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
293
|
+
const sourceDir = dirname(resolvedPath);
|
|
294
|
+
if (basename(sourceDir) !== 'src') {
|
|
149
295
|
return null;
|
|
150
296
|
}
|
|
151
297
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
298
|
+
const packageRoot = dirname(sourceDir);
|
|
299
|
+
if (!existsSync(join(packageRoot, 'package.json'))) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return packageRoot;
|
|
158
304
|
};
|
|
159
305
|
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
const callee = node['callee'] as AstNode | undefined;
|
|
166
|
-
if (!callee) {
|
|
306
|
+
/** Fallback exemption when framework files are consumed from a different install path. */
|
|
307
|
+
const isFrameworkDraftPrefixSourceFile = (filePath: string): boolean => {
|
|
308
|
+
const root = frameworkDraftPackageRoot(filePath);
|
|
309
|
+
if (!root) {
|
|
167
310
|
return false;
|
|
168
311
|
}
|
|
312
|
+
const packageName = readPackageJsonName(join(root, 'package.json'));
|
|
313
|
+
return (
|
|
314
|
+
packageName !== null && FRAMEWORK_DRAFT_PREFIX_PACKAGES.has(packageName)
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Absolute paths of the two framework files allowed to declare the
|
|
320
|
+
* draft-prefix constants. Anchored against the rule module's own URL so the
|
|
321
|
+
* exemption is scoped to this package's real on-disk location — a consumer
|
|
322
|
+
* repository that happens to declare `const DRAFT_ID_PREFIX = '_draft.leak'`
|
|
323
|
+
* anywhere else cannot hide a genuine leak by matching the identifier name.
|
|
324
|
+
*
|
|
325
|
+
* The two framework files are:
|
|
326
|
+
* - `packages/core/src/draft.ts` (defines `DRAFT_ID_PREFIX`)
|
|
327
|
+
* - `packages/warden/src/draft.ts` (defines `DRAFT_FILE_PREFIX`)
|
|
328
|
+
*/
|
|
329
|
+
const FRAMEWORK_DRAFT_CONSTANT_FILES: ReadonlySet<string> = new Set([
|
|
330
|
+
resolve(
|
|
331
|
+
fileURLToPath(new URL('../../../core/src/draft.ts', import.meta.url))
|
|
332
|
+
),
|
|
333
|
+
resolve(fileURLToPath(new URL('../draft.ts', import.meta.url))),
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Collect the source offsets of string literals that initialize a framework
|
|
338
|
+
* draft-prefix constant declaration (e.g. `export const DRAFT_ID_PREFIX =
|
|
339
|
+
* '_draft.'`). Used by draft-awareness rules to skip their own marker
|
|
340
|
+
* constants.
|
|
341
|
+
*
|
|
342
|
+
* Exemption is gated on all three of:
|
|
343
|
+
* 1. The file is one of the two known framework draft files, or its package
|
|
344
|
+
* root `package.json` name is `@ontrails/core` or `@ontrails/warden`.
|
|
345
|
+
* 2. The declaration name is `DRAFT_ID_PREFIX` or `DRAFT_FILE_PREFIX`.
|
|
346
|
+
* 3. The string literal value is exactly `'_draft.'`.
|
|
347
|
+
*
|
|
348
|
+
* A consumer file that reuses one of these identifier names cannot hide a
|
|
349
|
+
* `_draft.*` leak — the path gate rejects it outright.
|
|
350
|
+
*/
|
|
351
|
+
export const collectFrameworkDraftPrefixConstantOffsets = (
|
|
352
|
+
ast: AstNode,
|
|
353
|
+
filePath: string
|
|
354
|
+
): ReadonlySet<number> => {
|
|
355
|
+
const offsets = new Set<number>();
|
|
356
|
+
|
|
357
|
+
const resolvedPath = resolve(filePath);
|
|
169
358
|
if (
|
|
170
|
-
|
|
171
|
-
|
|
359
|
+
!FRAMEWORK_DRAFT_CONSTANT_FILES.has(resolvedPath) &&
|
|
360
|
+
!isFrameworkDraftPrefixSourceFile(resolvedPath)
|
|
172
361
|
) {
|
|
362
|
+
return offsets;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
walk(ast, (node) => {
|
|
366
|
+
if (node.type !== 'VariableDeclarator') {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const { id, init } = node as unknown as {
|
|
371
|
+
readonly id?: AstNode;
|
|
372
|
+
readonly init?: AstNode;
|
|
373
|
+
};
|
|
374
|
+
const name = identifierName(id);
|
|
375
|
+
if (
|
|
376
|
+
!name ||
|
|
377
|
+
!FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES.has(name) ||
|
|
378
|
+
!init ||
|
|
379
|
+
!isStringLiteral(init)
|
|
380
|
+
) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (getStringValue(init) !== FRAMEWORK_DRAFT_PREFIX_LITERAL) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
offsets.add(init.start);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
return offsets;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const WARDEN_IGNORE_NEXT_LINE_PRAGMAS = new Set([
|
|
395
|
+
'// warden-ignore-next-line',
|
|
396
|
+
'<!-- warden-ignore-next-line -->',
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Split source code into lines for pragma lookups. Callers should split once
|
|
401
|
+
* per `check` invocation and thread the result through to
|
|
402
|
+
* {@link hasIgnoreCommentOnLine} so we avoid re-splitting the full source on
|
|
403
|
+
* every match in files with many draft-like string literals.
|
|
404
|
+
*/
|
|
405
|
+
export const splitSourceLines = (sourceCode: string): readonly string[] =>
|
|
406
|
+
sourceCode.split('\n');
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Check whether the line immediately preceding `line` contains a
|
|
410
|
+
* `warden-ignore-next-line` pragma (leading/trailing whitespace tolerated).
|
|
411
|
+
* Pragma scope is strictly one line — an intervening blank line breaks it.
|
|
412
|
+
*
|
|
413
|
+
* Takes a pre-split `lines` array so callers can split the source once per
|
|
414
|
+
* invocation instead of re-splitting for every literal they check.
|
|
415
|
+
*
|
|
416
|
+
* @example
|
|
417
|
+
* ```ts
|
|
418
|
+
* // warden-ignore-next-line
|
|
419
|
+
* const x = '_draft.intentional'; // suppressed
|
|
420
|
+
* ```
|
|
421
|
+
*/
|
|
422
|
+
export const hasIgnoreCommentOnLine = (
|
|
423
|
+
lines: readonly string[],
|
|
424
|
+
line: number
|
|
425
|
+
): boolean => {
|
|
426
|
+
if (line <= 1) {
|
|
173
427
|
return false;
|
|
174
428
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
429
|
+
|
|
430
|
+
const previous = lines[line - 2];
|
|
431
|
+
if (previous === undefined) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return WARDEN_IGNORE_NEXT_LINE_PRAGMAS.has(previous.trim());
|
|
180
436
|
};
|
|
181
437
|
|
|
182
|
-
export const
|
|
183
|
-
|
|
438
|
+
export const findStringLiterals = (
|
|
439
|
+
ast: AstNode,
|
|
440
|
+
predicate?: (value: string, node: AstNode) => boolean
|
|
441
|
+
): StringLiteralMatch[] => {
|
|
442
|
+
const matches: StringLiteralMatch[] = [];
|
|
184
443
|
|
|
185
444
|
walk(ast, (node) => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
445
|
+
if (!isStringLiteral(node)) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const value = getStringValue(node);
|
|
450
|
+
if (value === null) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (predicate && !predicate(value, node)) {
|
|
455
|
+
return;
|
|
189
456
|
}
|
|
457
|
+
|
|
458
|
+
matches.push({
|
|
459
|
+
end: node.end,
|
|
460
|
+
node,
|
|
461
|
+
start: node.start,
|
|
462
|
+
value,
|
|
463
|
+
});
|
|
190
464
|
});
|
|
191
465
|
|
|
192
|
-
return
|
|
466
|
+
return matches;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/** Extract the first string argument from a CallExpression. */
|
|
470
|
+
export const extractFirstStringArg = (node: AstNode): string | null => {
|
|
471
|
+
if (node.type !== 'CallExpression') {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
476
|
+
const [firstArg] = args ?? [];
|
|
477
|
+
return extractStringLiteral(firstArg);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const isResourceCall = (node: AstNode | undefined): boolean =>
|
|
481
|
+
!!node &&
|
|
482
|
+
node.type === 'CallExpression' &&
|
|
483
|
+
identifierName((node as unknown as { callee?: AstNode }).callee) ===
|
|
484
|
+
'resource';
|
|
485
|
+
|
|
486
|
+
const extractBindingName = (node: AstNode | undefined): string | null => {
|
|
487
|
+
if (!node) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
if (node.type === 'Identifier') {
|
|
491
|
+
return identifierName(node);
|
|
492
|
+
}
|
|
493
|
+
if (node.type === 'AssignmentPattern') {
|
|
494
|
+
return identifierName((node as unknown as { left?: AstNode }).left);
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/** Collect `const foo = resource('id', ...)` bindings from a parsed file. */
|
|
500
|
+
export const collectNamedResourceIds = (
|
|
501
|
+
ast: AstNode
|
|
502
|
+
): ReadonlyMap<string, string> => {
|
|
503
|
+
const ids = new Map<string, string>();
|
|
504
|
+
|
|
505
|
+
walk(ast, (node) => {
|
|
506
|
+
if (node.type !== 'VariableDeclarator') {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const { id, init } = node as unknown as {
|
|
511
|
+
readonly id?: AstNode;
|
|
512
|
+
readonly init?: AstNode;
|
|
513
|
+
};
|
|
514
|
+
if (!isResourceCall(init)) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const name = extractBindingName(id);
|
|
519
|
+
const resourceId = init ? extractFirstStringArg(init) : null;
|
|
520
|
+
if (name && resourceId) {
|
|
521
|
+
ids.set(name, resourceId);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
return ids;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/** Collect all inline `resource('id', ...)` definition IDs from a parsed file. */
|
|
529
|
+
export const collectResourceDefinitionIds = (
|
|
530
|
+
ast: AstNode
|
|
531
|
+
): ReadonlySet<string> => {
|
|
532
|
+
const ids = new Set<string>();
|
|
533
|
+
|
|
534
|
+
walk(ast, (node) => {
|
|
535
|
+
if (!isResourceCall(node)) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const id = extractFirstStringArg(node);
|
|
540
|
+
if (id) {
|
|
541
|
+
ids.add(id);
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
return ids;
|
|
193
546
|
};
|
|
194
547
|
|
|
195
548
|
// ---------------------------------------------------------------------------
|
|
196
549
|
// Config property extraction helpers
|
|
197
550
|
// ---------------------------------------------------------------------------
|
|
198
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Extract the identifying name of a `Property` key, supporting both
|
|
554
|
+
* identifier keys (`{ foo: 1 }`) and string-literal keys
|
|
555
|
+
* (`{ "foo": 1 }`). Computed keys are intentionally not resolved — a
|
|
556
|
+
* computed expression could evaluate to anything and we only want to
|
|
557
|
+
* match keys that are statically equivalent to a plain identifier.
|
|
558
|
+
*/
|
|
559
|
+
const staticPropertyKeyName = (key: AstNode): string | null => {
|
|
560
|
+
if (key.type === 'Identifier') {
|
|
561
|
+
return (key as unknown as { name?: string }).name ?? null;
|
|
562
|
+
}
|
|
563
|
+
return isStringLiteral(key) ? getStringValue(key) : null;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const propertyKeyName = (prop: AstNode): string | null => {
|
|
567
|
+
if (prop.type !== 'Property') {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
const { computed } = prop as unknown as { computed?: boolean };
|
|
571
|
+
if (computed) {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
const key = prop.key as AstNode | undefined;
|
|
575
|
+
return key ? staticPropertyKeyName(key) : null;
|
|
576
|
+
};
|
|
577
|
+
|
|
199
578
|
/** Find a Property node by key name inside an ObjectExpression config. */
|
|
200
579
|
export const findConfigProperty = (
|
|
201
580
|
config: AstNode,
|
|
@@ -209,9 +588,2721 @@ export const findConfigProperty = (
|
|
|
209
588
|
return null;
|
|
210
589
|
}
|
|
211
590
|
for (const prop of properties) {
|
|
212
|
-
if (prop
|
|
591
|
+
if (propertyKeyName(prop) === propertyName) {
|
|
213
592
|
return prop;
|
|
214
593
|
}
|
|
215
594
|
}
|
|
216
595
|
return null;
|
|
217
596
|
};
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// Trail definition extraction
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
export interface TrailDefinition {
|
|
603
|
+
/** Trail ID string, e.g. "entity.show" */
|
|
604
|
+
readonly id: string;
|
|
605
|
+
/** "trail" or "signal" */
|
|
606
|
+
readonly kind: string;
|
|
607
|
+
/** The config object argument (second arg to trail() call) */
|
|
608
|
+
readonly config: AstNode;
|
|
609
|
+
/** Start offset of the call expression */
|
|
610
|
+
readonly start: number;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Find all `trail("id", { ... })`, `trail({ id: "x", ... })`, and
|
|
615
|
+
* `signal("id", { ... })` call sites.
|
|
616
|
+
*
|
|
617
|
+
* Returns the trail ID, kind, and config object node for each definition.
|
|
618
|
+
*/
|
|
619
|
+
const TRAIL_CALLEE_NAMES = new Set(['signal', 'trail']);
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Source prefix for the Trails framework package whose namespace imports are
|
|
623
|
+
* recognized as carriers of `trail()` / `signal()` / `contour()` primitives.
|
|
624
|
+
*
|
|
625
|
+
* A namespaced callee like `core.trail(...)` is only treated as a framework
|
|
626
|
+
* call when the receiver identifier resolves to an `import * as core from
|
|
627
|
+
* '@ontrails/...'` in the same file. An unrelated `analytics.trail(...)`
|
|
628
|
+
* whose `analytics` comes from a different module (or no import at all)
|
|
629
|
+
* is ignored.
|
|
630
|
+
*/
|
|
631
|
+
const FRAMEWORK_NAMESPACE_SOURCE_PREFIX = '@ontrails/';
|
|
632
|
+
|
|
633
|
+
const isFrameworkNamespaceSource = (value: unknown): boolean =>
|
|
634
|
+
typeof value === 'string' &&
|
|
635
|
+
value.startsWith(FRAMEWORK_NAMESPACE_SOURCE_PREFIX);
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Collect local binding names introduced by `import * as <name> from
|
|
639
|
+
* '@ontrails/...'` declarations. Used to gate namespaced framework-primitive
|
|
640
|
+
* calls so an unrelated `analytics.trail(...)` doesn't match.
|
|
641
|
+
*/
|
|
642
|
+
const getImportSourceValue = (node: AstNode): unknown => {
|
|
643
|
+
const sourceNode = (node as unknown as { source?: AstNode }).source;
|
|
644
|
+
return sourceNode
|
|
645
|
+
? (sourceNode as unknown as { value?: unknown }).value
|
|
646
|
+
: undefined;
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const addNamespaceImportBindings = (
|
|
650
|
+
node: AstNode,
|
|
651
|
+
names: Set<string>
|
|
652
|
+
): void => {
|
|
653
|
+
const specifiers =
|
|
654
|
+
(node['specifiers'] as readonly AstNode[] | undefined) ?? [];
|
|
655
|
+
for (const spec of specifiers) {
|
|
656
|
+
if (spec.type !== 'ImportNamespaceSpecifier') {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const { local } = spec as unknown as { local?: AstNode };
|
|
660
|
+
const localName = identifierName(local);
|
|
661
|
+
if (localName) {
|
|
662
|
+
names.add(localName);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const TOP_LEVEL_NAMED_DECL_TYPES = new Set([
|
|
668
|
+
'ClassDeclaration',
|
|
669
|
+
'FunctionDeclaration',
|
|
670
|
+
'TSEnumDeclaration',
|
|
671
|
+
'TSModuleDeclaration',
|
|
672
|
+
]);
|
|
673
|
+
|
|
674
|
+
const removeVarDeclarationShadowedNames = (
|
|
675
|
+
stmt: AstNode,
|
|
676
|
+
names: Set<string>
|
|
677
|
+
): void => {
|
|
678
|
+
const declarations =
|
|
679
|
+
(stmt as unknown as { declarations?: readonly AstNode[] }).declarations ??
|
|
680
|
+
[];
|
|
681
|
+
for (const d of declarations) {
|
|
682
|
+
const { id } = d as unknown as { id?: AstNode };
|
|
683
|
+
const n = identifierName(id);
|
|
684
|
+
if (n) {
|
|
685
|
+
names.delete(n);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const removeNamedDeclShadowedName = (
|
|
691
|
+
stmt: AstNode,
|
|
692
|
+
names: Set<string>
|
|
693
|
+
): void => {
|
|
694
|
+
const { id } = stmt as unknown as { id?: AstNode };
|
|
695
|
+
const n = identifierName(id);
|
|
696
|
+
if (n) {
|
|
697
|
+
names.delete(n);
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const removeTopLevelShadowedNames = (
|
|
702
|
+
stmt: AstNode,
|
|
703
|
+
names: Set<string>
|
|
704
|
+
): void => {
|
|
705
|
+
if (
|
|
706
|
+
stmt.type === 'ExportNamedDeclaration' ||
|
|
707
|
+
stmt.type === 'ExportDefaultDeclaration'
|
|
708
|
+
) {
|
|
709
|
+
const { declaration } = stmt as unknown as { declaration?: AstNode };
|
|
710
|
+
if (declaration) {
|
|
711
|
+
removeTopLevelShadowedNames(declaration, names);
|
|
712
|
+
}
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (stmt.type === 'VariableDeclaration') {
|
|
716
|
+
removeVarDeclarationShadowedNames(stmt, names);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (TOP_LEVEL_NAMED_DECL_TYPES.has(stmt.type)) {
|
|
720
|
+
removeNamedDeclShadowedName(stmt, names);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const collectFrameworkNamespaceBindings = (
|
|
725
|
+
ast: AstNode
|
|
726
|
+
): ReadonlySet<string> => {
|
|
727
|
+
const names = new Set<string>();
|
|
728
|
+
walk(ast, (node) => {
|
|
729
|
+
if (node.type !== 'ImportDeclaration') {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (!isFrameworkNamespaceSource(getImportSourceValue(node))) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
addNamespaceImportBindings(node, names);
|
|
736
|
+
});
|
|
737
|
+
if (names.size === 0) {
|
|
738
|
+
return names;
|
|
739
|
+
}
|
|
740
|
+
// A same-named top-level declaration (class / enum / namespace / var /
|
|
741
|
+
// function / lexical binding) shadows the namespace import at module scope.
|
|
742
|
+
// The scope walker treats Program as the outermost frame and skips it when
|
|
743
|
+
// testing for inner shadows, so we have to strip these collisions here.
|
|
744
|
+
if (ast.type === 'Program') {
|
|
745
|
+
const body = (ast as unknown as { body?: readonly AstNode[] }).body ?? [];
|
|
746
|
+
for (const stmt of body) {
|
|
747
|
+
removeTopLevelShadowedNames(stmt, names);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return names;
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// Scope-aware framework-namespace resolution
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
//
|
|
757
|
+
// A module-level `import * as core from '@ontrails/core'` makes `core` a
|
|
758
|
+
// framework-namespace binding, but a function-local `const core = {...}` (or
|
|
759
|
+
// param, `let`, `var`, `function`, class, catch param) shadows the import for
|
|
760
|
+
// the duration of that scope. A name-only check is not enough to trust
|
|
761
|
+
// `core.trail(...)` — we have to walk scopes outward from each call site and
|
|
762
|
+
// verify the first declaration of the receiver IS the namespace import.
|
|
763
|
+
//
|
|
764
|
+
// {@link collectFrameworkNamespacedCallStarts} performs that walk once per
|
|
765
|
+
// AST and returns the set of `CallExpression` start offsets whose receiver is
|
|
766
|
+
// provably the framework binding. Downstream helpers gate on this set instead
|
|
767
|
+
// of the bare names, so a local shadow cannot sneak through.
|
|
768
|
+
|
|
769
|
+
type PatternExpander = (node: AstNode) => readonly AstNode[];
|
|
770
|
+
|
|
771
|
+
const expandAssignmentPattern: PatternExpander = (node) => {
|
|
772
|
+
const { left } = node as unknown as { left?: AstNode };
|
|
773
|
+
return left ? [left] : [];
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const expandRestElement: PatternExpander = (node) => {
|
|
777
|
+
const { argument } = node as unknown as { argument?: AstNode };
|
|
778
|
+
return argument ? [argument] : [];
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const expandArrayPattern: PatternExpander = (node) => {
|
|
782
|
+
const elements =
|
|
783
|
+
(node as unknown as { elements?: readonly (AstNode | null)[] }).elements ??
|
|
784
|
+
[];
|
|
785
|
+
return elements.filter((e): e is AstNode => e !== null);
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const expandObjectPatternProperty = (prop: AstNode): AstNode | null => {
|
|
789
|
+
if (prop.type === 'RestElement') {
|
|
790
|
+
return prop;
|
|
791
|
+
}
|
|
792
|
+
const { value } = prop as unknown as { value?: AstNode };
|
|
793
|
+
return value ?? null;
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const expandObjectPattern: PatternExpander = (node) => {
|
|
797
|
+
const properties =
|
|
798
|
+
(node as unknown as { properties?: readonly AstNode[] }).properties ?? [];
|
|
799
|
+
return properties
|
|
800
|
+
.map(expandObjectPatternProperty)
|
|
801
|
+
.filter((n): n is AstNode => n !== null);
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
const PATTERN_EXPANDERS: Record<string, PatternExpander> = {
|
|
805
|
+
ArrayPattern: expandArrayPattern,
|
|
806
|
+
AssignmentPattern: expandAssignmentPattern,
|
|
807
|
+
ObjectPattern: expandObjectPattern,
|
|
808
|
+
RestElement: expandRestElement,
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const processPatternNode = (
|
|
812
|
+
node: AstNode,
|
|
813
|
+
into: Set<string>,
|
|
814
|
+
stack: AstNode[]
|
|
815
|
+
): void => {
|
|
816
|
+
if (node.type === 'Identifier') {
|
|
817
|
+
const { name } = node as unknown as { name?: string };
|
|
818
|
+
if (name) {
|
|
819
|
+
into.add(name);
|
|
820
|
+
}
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const expand = PATTERN_EXPANDERS[node.type];
|
|
824
|
+
if (expand) {
|
|
825
|
+
stack.push(...expand(node));
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
const addPatternBindingNames = (
|
|
830
|
+
pattern: AstNode | undefined,
|
|
831
|
+
into: Set<string>
|
|
832
|
+
): void => {
|
|
833
|
+
if (!pattern) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const stack: AstNode[] = [pattern];
|
|
837
|
+
while (stack.length > 0) {
|
|
838
|
+
const node = stack.pop();
|
|
839
|
+
if (node) {
|
|
840
|
+
processPatternNode(node, into, stack);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
const addVarDeclarationBindingNames = (
|
|
846
|
+
decl: AstNode,
|
|
847
|
+
into: Set<string>
|
|
848
|
+
): void => {
|
|
849
|
+
const declarations =
|
|
850
|
+
(decl as unknown as { declarations?: readonly AstNode[] }).declarations ??
|
|
851
|
+
[];
|
|
852
|
+
for (const d of declarations) {
|
|
853
|
+
addPatternBindingNames((d as unknown as { id?: AstNode }).id, into);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const addFunctionOrClassBindingName = (
|
|
858
|
+
node: AstNode,
|
|
859
|
+
into: Set<string>
|
|
860
|
+
): void => {
|
|
861
|
+
const { id } = node as unknown as { id?: AstNode };
|
|
862
|
+
const name = identifierName(id);
|
|
863
|
+
if (name) {
|
|
864
|
+
into.add(name);
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const addBlockStatementBindings = (stmt: AstNode, into: Set<string>): void => {
|
|
869
|
+
if (stmt.type === 'VariableDeclaration') {
|
|
870
|
+
addVarDeclarationBindingNames(stmt, into);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (
|
|
874
|
+
stmt.type === 'FunctionDeclaration' ||
|
|
875
|
+
stmt.type === 'ClassDeclaration' ||
|
|
876
|
+
stmt.type === 'TSEnumDeclaration' ||
|
|
877
|
+
stmt.type === 'TSModuleDeclaration'
|
|
878
|
+
) {
|
|
879
|
+
addFunctionOrClassBindingName(stmt, into);
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
const collectTopLevelStatementBindings = (
|
|
884
|
+
stmt: AstNode,
|
|
885
|
+
into: Set<string>
|
|
886
|
+
): void => {
|
|
887
|
+
if (
|
|
888
|
+
stmt.type === 'ExportNamedDeclaration' ||
|
|
889
|
+
stmt.type === 'ExportDefaultDeclaration'
|
|
890
|
+
) {
|
|
891
|
+
const { declaration } = stmt as unknown as { declaration?: AstNode };
|
|
892
|
+
if (declaration) {
|
|
893
|
+
collectTopLevelStatementBindings(declaration, into);
|
|
894
|
+
}
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
addBlockStatementBindings(stmt, into);
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
const FUNCTION_BOUNDARY_TYPES = new Set([
|
|
901
|
+
'ArrowFunctionExpression',
|
|
902
|
+
'FunctionDeclaration',
|
|
903
|
+
'FunctionExpression',
|
|
904
|
+
'StaticBlock',
|
|
905
|
+
]);
|
|
906
|
+
|
|
907
|
+
const forEachAstChild = (
|
|
908
|
+
node: AstNode,
|
|
909
|
+
visit: (child: AstNode) => void
|
|
910
|
+
): void => {
|
|
911
|
+
for (const val of Object.values(node)) {
|
|
912
|
+
if (Array.isArray(val)) {
|
|
913
|
+
for (const item of val) {
|
|
914
|
+
if (item && typeof item === 'object' && (item as AstNode).type) {
|
|
915
|
+
visit(item as AstNode);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
} else if (val && typeof val === 'object' && (val as AstNode).type) {
|
|
919
|
+
visit(val as AstNode);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
const recordHoistedBinding = (
|
|
925
|
+
node: AstNode,
|
|
926
|
+
into: Set<string>,
|
|
927
|
+
inNestedBlock: boolean
|
|
928
|
+
): void => {
|
|
929
|
+
if (node.type === 'VariableDeclaration') {
|
|
930
|
+
const { kind } = node as unknown as { kind?: string };
|
|
931
|
+
if (kind === 'var') {
|
|
932
|
+
addVarDeclarationBindingNames(node, into);
|
|
933
|
+
}
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
// In strict/module code, function/class/enum/module declarations inside a
|
|
937
|
+
// nested block (`if { function foo() {} }`, `switch` case, etc.) are
|
|
938
|
+
// block-scoped. Only hoist them to the enclosing function frame when they
|
|
939
|
+
// sit directly in the function body, not inside a further block.
|
|
940
|
+
if (inNestedBlock) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (
|
|
944
|
+
node.type === 'FunctionDeclaration' ||
|
|
945
|
+
node.type === 'ClassDeclaration' ||
|
|
946
|
+
node.type === 'TSEnumDeclaration' ||
|
|
947
|
+
node.type === 'TSModuleDeclaration'
|
|
948
|
+
) {
|
|
949
|
+
addFunctionOrClassBindingName(node, into);
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
const NESTED_BLOCK_BOUNDARY_TYPES = new Set([
|
|
954
|
+
'BlockStatement',
|
|
955
|
+
'ForStatement',
|
|
956
|
+
'ForInStatement',
|
|
957
|
+
'ForOfStatement',
|
|
958
|
+
'SwitchStatement',
|
|
959
|
+
'CatchClause',
|
|
960
|
+
]);
|
|
961
|
+
|
|
962
|
+
const visitForHoisted = (
|
|
963
|
+
node: AstNode,
|
|
964
|
+
isRoot: boolean,
|
|
965
|
+
into: Set<string>,
|
|
966
|
+
inNestedBlock: boolean
|
|
967
|
+
): void => {
|
|
968
|
+
if (!isRoot && FUNCTION_BOUNDARY_TYPES.has(node.type)) {
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
recordHoistedBinding(node, into, inNestedBlock);
|
|
972
|
+
const childInNestedBlock =
|
|
973
|
+
inNestedBlock || (!isRoot && NESTED_BLOCK_BOUNDARY_TYPES.has(node.type));
|
|
974
|
+
forEachAstChild(node, (child) => {
|
|
975
|
+
visitForHoisted(child, false, into, childInNestedBlock);
|
|
976
|
+
});
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Collect `var` declarations and `function` declarations hoisted to the
|
|
981
|
+
* nearest function scope from anywhere inside `root`, without composing a
|
|
982
|
+
* nested function or static-block boundary.
|
|
983
|
+
*/
|
|
984
|
+
const collectHoistedVarAndFunctionBindings = (
|
|
985
|
+
root: AstNode,
|
|
986
|
+
into: Set<string>
|
|
987
|
+
): void => {
|
|
988
|
+
visitForHoisted(root, true, into, false);
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
type FrameCollector = (node: AstNode, into: Set<string>) => void;
|
|
992
|
+
|
|
993
|
+
const collectProgramFrame: FrameCollector = (node, into) => {
|
|
994
|
+
const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
|
|
995
|
+
for (const stmt of body) {
|
|
996
|
+
collectTopLevelStatementBindings(stmt, into);
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
const collectFunctionFrame: FrameCollector = (node, into) => {
|
|
1001
|
+
const params =
|
|
1002
|
+
(node as unknown as { params?: readonly AstNode[] }).params ?? [];
|
|
1003
|
+
for (const param of params) {
|
|
1004
|
+
addPatternBindingNames(param, into);
|
|
1005
|
+
}
|
|
1006
|
+
// Hoisted vars and function declarations inside the body live in the
|
|
1007
|
+
// function's var-environment. A `var ns = ...;` inside an `if` still
|
|
1008
|
+
// shadows a module-level `ns` for the whole function.
|
|
1009
|
+
const { body } = node as unknown as { body?: AstNode };
|
|
1010
|
+
if (body) {
|
|
1011
|
+
collectHoistedVarAndFunctionBindings(body, into);
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const collectBlockFrame: FrameCollector = (node, into) => {
|
|
1016
|
+
const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
|
|
1017
|
+
for (const stmt of body) {
|
|
1018
|
+
addBlockStatementBindings(stmt, into);
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const collectForStatementFrame: FrameCollector = (node, into) => {
|
|
1023
|
+
const { init } = node as unknown as { init?: AstNode };
|
|
1024
|
+
if (init && init.type === 'VariableDeclaration') {
|
|
1025
|
+
addVarDeclarationBindingNames(init, into);
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
const collectForInOfFrame: FrameCollector = (node, into) => {
|
|
1030
|
+
const { left } = node as unknown as { left?: AstNode };
|
|
1031
|
+
if (left && left.type === 'VariableDeclaration') {
|
|
1032
|
+
addVarDeclarationBindingNames(left, into);
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
const collectSwitchStatementFrame: FrameCollector = (node, into) => {
|
|
1037
|
+
// `switch` shares one scope across every case. A binding in one case
|
|
1038
|
+
// shadows the namespace across sibling cases (fall-through or otherwise).
|
|
1039
|
+
const cases = (node as unknown as { cases?: readonly AstNode[] }).cases ?? [];
|
|
1040
|
+
for (const c of cases) {
|
|
1041
|
+
const consequent =
|
|
1042
|
+
(c as unknown as { consequent?: readonly AstNode[] }).consequent ?? [];
|
|
1043
|
+
for (const stmt of consequent) {
|
|
1044
|
+
addBlockStatementBindings(stmt, into);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
const collectCatchClauseFrame: FrameCollector = (node, into) => {
|
|
1050
|
+
const { param } = node as unknown as { param?: AstNode };
|
|
1051
|
+
addPatternBindingNames(param, into);
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
const collectClassExpressionFrame: FrameCollector = (node, into) => {
|
|
1055
|
+
// A named `class expr` (`const C = class foo { ... }`) binds its own name
|
|
1056
|
+
// inside its body only. ClassDeclaration names are hoisted into the
|
|
1057
|
+
// enclosing block/program frame instead, so only class *expression* names
|
|
1058
|
+
// need their own frame here.
|
|
1059
|
+
addFunctionOrClassBindingName(node, into);
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
const SCOPE_FRAME_COLLECTORS: Record<string, FrameCollector> = {
|
|
1063
|
+
ArrowFunctionExpression: collectFunctionFrame,
|
|
1064
|
+
BlockStatement: collectBlockFrame,
|
|
1065
|
+
CatchClause: collectCatchClauseFrame,
|
|
1066
|
+
ClassExpression: collectClassExpressionFrame,
|
|
1067
|
+
ForInStatement: collectForInOfFrame,
|
|
1068
|
+
ForOfStatement: collectForInOfFrame,
|
|
1069
|
+
ForStatement: collectForStatementFrame,
|
|
1070
|
+
// oxc-parser emits `FunctionBody` for `function` expression bodies; without
|
|
1071
|
+
// this entry, a `const ns = ...` at the top of a function-expression body
|
|
1072
|
+
// would not push a scope frame, and a module-level namespace import with
|
|
1073
|
+
// the same name would be incorrectly recognized inside.
|
|
1074
|
+
FunctionBody: collectBlockFrame,
|
|
1075
|
+
FunctionDeclaration: collectFunctionFrame,
|
|
1076
|
+
FunctionExpression: collectFunctionFrame,
|
|
1077
|
+
Program: collectProgramFrame,
|
|
1078
|
+
StaticBlock: collectBlockFrame,
|
|
1079
|
+
SwitchStatement: collectSwitchStatementFrame,
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Collect the identifier bindings introduced *directly* by a scope frame
|
|
1084
|
+
* node. Scope frames correspond to JS lexical scopes (function bodies, blocks,
|
|
1085
|
+
* catch clauses, for-statements, switch statements, module/script roots).
|
|
1086
|
+
*/
|
|
1087
|
+
export const collectScopeFrameBindings = (
|
|
1088
|
+
node: AstNode
|
|
1089
|
+
): ReadonlySet<string> => {
|
|
1090
|
+
const names = new Set<string>();
|
|
1091
|
+
const collector = SCOPE_FRAME_COLLECTORS[node.type];
|
|
1092
|
+
if (collector) {
|
|
1093
|
+
collector(node, names);
|
|
1094
|
+
}
|
|
1095
|
+
return names;
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
export type ScopeAwareVisitor = (
|
|
1099
|
+
node: AstNode,
|
|
1100
|
+
scopes: readonly ReadonlySet<string>[]
|
|
1101
|
+
) => void;
|
|
1102
|
+
|
|
1103
|
+
export interface ScopeWalkOptions {
|
|
1104
|
+
readonly initialScopes?: readonly ReadonlySet<string>[];
|
|
1105
|
+
readonly stopAtNestedFunctions?: boolean;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const asAstNode = (node: unknown): AstNode | null => {
|
|
1109
|
+
if (!node || typeof node !== 'object') {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
const astNode = node as AstNode;
|
|
1113
|
+
return astNode.type ? astNode : null;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Walk an AST subtree while threading lexical scope bindings through each
|
|
1118
|
+
* visit. Callers can seed outer scopes and optionally stop at nested function
|
|
1119
|
+
* boundaries when only the current implementation body should be analyzed.
|
|
1120
|
+
*/
|
|
1121
|
+
export const walkWithScopes = (
|
|
1122
|
+
node: unknown,
|
|
1123
|
+
visit: ScopeAwareVisitor,
|
|
1124
|
+
options: ScopeWalkOptions = {}
|
|
1125
|
+
): void => {
|
|
1126
|
+
const root = asAstNode(node);
|
|
1127
|
+
if (!root) {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const stack = [...(options.initialScopes ?? [])];
|
|
1132
|
+
|
|
1133
|
+
const walkNode = (current: AstNode, isRoot: boolean): void => {
|
|
1134
|
+
if (
|
|
1135
|
+
!isRoot &&
|
|
1136
|
+
options.stopAtNestedFunctions &&
|
|
1137
|
+
FUNCTION_BOUNDARY_TYPES.has(current.type)
|
|
1138
|
+
) {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const isScope = current.type in SCOPE_FRAME_COLLECTORS;
|
|
1143
|
+
if (isScope) {
|
|
1144
|
+
stack.unshift(collectScopeFrameBindings(current));
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
try {
|
|
1148
|
+
visit(current, stack);
|
|
1149
|
+
forEachAstChild(current, (child) => {
|
|
1150
|
+
walkNode(child, false);
|
|
1151
|
+
});
|
|
1152
|
+
} finally {
|
|
1153
|
+
if (isScope) {
|
|
1154
|
+
stack.shift();
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
walkNode(root, true);
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
export const isShadowed = (
|
|
1163
|
+
receiverName: string,
|
|
1164
|
+
scopeStack: readonly ReadonlySet<string>[]
|
|
1165
|
+
): boolean => {
|
|
1166
|
+
// The module-level Program frame is the last entry and contains the
|
|
1167
|
+
// namespace imports themselves. A "shadow" must come from a frame *inside*
|
|
1168
|
+
// that one — i.e. any frame except the outermost.
|
|
1169
|
+
for (let i = 0; i < scopeStack.length - 1; i += 1) {
|
|
1170
|
+
const frame = scopeStack[i];
|
|
1171
|
+
if (frame?.has(receiverName)) {
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return false;
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Return `true` when `node` is a non-computed member access (`a.b` /
|
|
1180
|
+
* `a?.b`) and `false` for anything else, including computed access
|
|
1181
|
+
* (`a[b]`) or non-member nodes. Exported as the canonical predicate so
|
|
1182
|
+
* rule modules do not re-implement the check.
|
|
1183
|
+
*
|
|
1184
|
+
* @remarks
|
|
1185
|
+
* Declared near the top of the file so the scope walker can use it
|
|
1186
|
+
* without hitting `no-use-before-define`. A few sibling helpers in this
|
|
1187
|
+
* module still inline the same shape under different local names for
|
|
1188
|
+
* historical reasons; prefer this export for new call sites.
|
|
1189
|
+
*/
|
|
1190
|
+
export const isMemberAccessNonComputed = (node: AstNode): boolean => {
|
|
1191
|
+
if (
|
|
1192
|
+
node.type !== 'MemberExpression' &&
|
|
1193
|
+
node.type !== 'StaticMemberExpression'
|
|
1194
|
+
) {
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
return (node as unknown as { computed?: boolean }).computed !== true;
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
const resolveNamespacedMemberNames = (
|
|
1201
|
+
callee: AstNode
|
|
1202
|
+
): { readonly receiver: string; readonly property: string } | null => {
|
|
1203
|
+
if (!isMemberAccessNonComputed(callee)) {
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
const { object } = callee as unknown as { object?: AstNode };
|
|
1207
|
+
const receiver = identifierName(object);
|
|
1208
|
+
if (!receiver) {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
const prop = (callee as unknown as { property?: AstNode }).property;
|
|
1212
|
+
const property =
|
|
1213
|
+
prop?.type === 'Identifier'
|
|
1214
|
+
? ((prop as unknown as { name?: string }).name ?? null)
|
|
1215
|
+
: null;
|
|
1216
|
+
return property ? { property, receiver } : null;
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
const getFrameworkCallReceiver = (
|
|
1220
|
+
node: AstNode,
|
|
1221
|
+
frameworkNamespaces: ReadonlySet<string>
|
|
1222
|
+
): string | null => {
|
|
1223
|
+
if (node.type !== 'CallExpression') {
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
const callee = node['callee'] as AstNode | undefined;
|
|
1227
|
+
if (!callee) {
|
|
1228
|
+
return null;
|
|
1229
|
+
}
|
|
1230
|
+
const names = resolveNamespacedMemberNames(callee);
|
|
1231
|
+
if (!names || !frameworkNamespaces.has(names.receiver)) {
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
return names.receiver;
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Walk the AST with a scope stack and collect `CallExpression` start offsets
|
|
1239
|
+
* whose callee is `<receiver>.<property>` where `<receiver>` is proven to
|
|
1240
|
+
* resolve to a framework namespace import (i.e. not shadowed by any
|
|
1241
|
+
* enclosing scope). Used to gate namespaced `core.trail(...)` /
|
|
1242
|
+
* `core.signal(...)` / `core.contour(...)` resolution against local shadows.
|
|
1243
|
+
*/
|
|
1244
|
+
const collectFrameworkNamespacedCallStarts = (
|
|
1245
|
+
ast: AstNode,
|
|
1246
|
+
frameworkNamespaces: ReadonlySet<string>
|
|
1247
|
+
): ReadonlySet<number> => {
|
|
1248
|
+
const starts = new Set<number>();
|
|
1249
|
+
if (frameworkNamespaces.size === 0) {
|
|
1250
|
+
return starts;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
walkWithScopes(ast, (node, scopes) => {
|
|
1254
|
+
const receiver = getFrameworkCallReceiver(node, frameworkNamespaces);
|
|
1255
|
+
if (!receiver || isShadowed(receiver, scopes)) {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
starts.add(node.start);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
return starts;
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
const matchTrailPrimitiveName = (
|
|
1265
|
+
name: string | undefined | null
|
|
1266
|
+
): string | null => (name && TRAIL_CALLEE_NAMES.has(name) ? name : null);
|
|
1267
|
+
|
|
1268
|
+
const getBareTrailCalleeName = (callee: AstNode): string | null => {
|
|
1269
|
+
if (callee.type !== 'Identifier') {
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
return matchTrailPrimitiveName((callee as unknown as { name?: string }).name);
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Extract the `{ receiverName, propertyName }` of a non-computed member-call
|
|
1277
|
+
* callee, or null for anything else. Computed access (`ns[trail]()`) is
|
|
1278
|
+
* intentionally rejected: the bracketed expression may resolve to any runtime
|
|
1279
|
+
* value, so we cannot prove the call targets a specific member.
|
|
1280
|
+
*/
|
|
1281
|
+
const isNonComputedMemberAccess = (callee: AstNode): boolean => {
|
|
1282
|
+
if (
|
|
1283
|
+
callee.type !== 'MemberExpression' &&
|
|
1284
|
+
callee.type !== 'StaticMemberExpression'
|
|
1285
|
+
) {
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
return (callee as unknown as { computed?: boolean }).computed !== true;
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
const getNamespacedMemberNames = (
|
|
1292
|
+
callee: AstNode
|
|
1293
|
+
): { readonly receiver: string; readonly property: string } | null => {
|
|
1294
|
+
if (!isNonComputedMemberAccess(callee)) {
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
const { object } = callee as unknown as { object?: AstNode };
|
|
1298
|
+
const receiver = identifierName(object);
|
|
1299
|
+
if (!receiver) {
|
|
1300
|
+
return null;
|
|
1301
|
+
}
|
|
1302
|
+
const prop = (callee as unknown as { property?: AstNode }).property;
|
|
1303
|
+
const property =
|
|
1304
|
+
prop?.type === 'Identifier'
|
|
1305
|
+
? ((prop as unknown as { name?: string }).name ?? null)
|
|
1306
|
+
: null;
|
|
1307
|
+
return property ? { property, receiver } : null;
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Resolution context for namespaced framework-primitive calls. Bundles the
|
|
1312
|
+
* bare namespace-binding set with an optional set of proven-safe
|
|
1313
|
+
* `CallExpression` start offsets from a scope-aware pre-pass. When the set of
|
|
1314
|
+
* safe starts is present, a namespaced call only resolves if its start is in
|
|
1315
|
+
* that set — so a function-local shadow of the namespace import does not
|
|
1316
|
+
* leak through. When absent (e.g. from test helpers), the name-only gate is
|
|
1317
|
+
* used as a backward-compatible fallback.
|
|
1318
|
+
*/
|
|
1319
|
+
export interface FrameworkNamespaceContext {
|
|
1320
|
+
readonly namespaces: ReadonlySet<string>;
|
|
1321
|
+
readonly safeCallStarts?: ReadonlySet<number>;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const asNamespaceContext = (
|
|
1325
|
+
input: ReadonlySet<string> | FrameworkNamespaceContext | undefined
|
|
1326
|
+
): FrameworkNamespaceContext | undefined => {
|
|
1327
|
+
if (!input) {
|
|
1328
|
+
return undefined;
|
|
1329
|
+
}
|
|
1330
|
+
return input instanceof Set
|
|
1331
|
+
? { namespaces: input }
|
|
1332
|
+
: (input as FrameworkNamespaceContext);
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
const isNamespacedCallAllowed = (
|
|
1336
|
+
callStart: number,
|
|
1337
|
+
receiver: string,
|
|
1338
|
+
ctx: FrameworkNamespaceContext
|
|
1339
|
+
): boolean => {
|
|
1340
|
+
if (!ctx.namespaces.has(receiver)) {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
// When `safeCallStarts` is present, it is the authoritative gate — it was
|
|
1344
|
+
// built by a scope-aware pre-pass and already excludes shadowed receivers.
|
|
1345
|
+
// Without it, fall back to the bare name check (used by unit-test hooks).
|
|
1346
|
+
return ctx.safeCallStarts ? ctx.safeCallStarts.has(callStart) : true;
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Resolve a namespaced `ns.trail(...)` / `ns.signal(...)` callee to its
|
|
1351
|
+
* primitive name. When a {@link FrameworkNamespaceContext} is provided, the
|
|
1352
|
+
* receiver must be a framework namespace binding AND — when a
|
|
1353
|
+
* `safeCallStarts` set is present — the call site must appear in that set,
|
|
1354
|
+
* meaning the receiver is not shadowed by any enclosing scope.
|
|
1355
|
+
*
|
|
1356
|
+
* When `context` is `undefined`, this falls back to permissive matching
|
|
1357
|
+
* (any `ns.trail(...)` shape resolves). Inline resolution paths that do
|
|
1358
|
+
* not have the surrounding AST available (e.g. `composes: [core.trail(...)]`
|
|
1359
|
+
* or `on: [core.signal(...)]`) rely on this fallback. Scope-aware call
|
|
1360
|
+
* sites always pass a context, so this only affects inline contexts where
|
|
1361
|
+
* a best-effort name match is the intended behavior.
|
|
1362
|
+
*/
|
|
1363
|
+
const getNamespacedTrailCalleeName = (
|
|
1364
|
+
callExpr: AstNode,
|
|
1365
|
+
callee: AstNode,
|
|
1366
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext
|
|
1367
|
+
): string | null => {
|
|
1368
|
+
const names = getNamespacedMemberNames(callee);
|
|
1369
|
+
if (!names) {
|
|
1370
|
+
return null;
|
|
1371
|
+
}
|
|
1372
|
+
const ctx = asNamespaceContext(context);
|
|
1373
|
+
if (ctx && !isNamespacedCallAllowed(callExpr.start, names.receiver, ctx)) {
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
return matchTrailPrimitiveName(names.property);
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Resolve the callee name of a trail/signal call expression.
|
|
1381
|
+
*
|
|
1382
|
+
* Matches both bare `trail(...)` / `signal(...)` identifiers and namespaced
|
|
1383
|
+
* member-expression callees like `core.trail(...)` or `ns.signal(...)`, where
|
|
1384
|
+
* the namespace must come from an `@ontrails/*` import and, when the scope
|
|
1385
|
+
* pre-pass is wired in, be unshadowed at the call site.
|
|
1386
|
+
*/
|
|
1387
|
+
const getTrailCalleeName = (
|
|
1388
|
+
node: AstNode,
|
|
1389
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext
|
|
1390
|
+
): string | null => {
|
|
1391
|
+
if (node.type !== 'CallExpression') {
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
const callee = node['callee'] as AstNode | undefined;
|
|
1395
|
+
if (!callee) {
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
return (
|
|
1399
|
+
getBareTrailCalleeName(callee) ??
|
|
1400
|
+
getNamespacedTrailCalleeName(node, callee, context)
|
|
1401
|
+
);
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Test hook: exposes {@link getTrailCalleeName} for unit tests.
|
|
1406
|
+
*
|
|
1407
|
+
* Kept unexported from the module's public surface (no re-export from
|
|
1408
|
+
* `index.ts`) so internal refactors stay free.
|
|
1409
|
+
*/
|
|
1410
|
+
export const __getTrailCalleeNameForTest = getTrailCalleeName;
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Test hook: exposes {@link collectFrameworkNamespaceBindings} for unit tests.
|
|
1414
|
+
*
|
|
1415
|
+
* Not re-exported from `index.ts`; the double-underscore prefix marks it as an
|
|
1416
|
+
* internal-only handle so consumer code cannot rely on it.
|
|
1417
|
+
*/
|
|
1418
|
+
export const __collectFrameworkNamespaceBindingsForTest =
|
|
1419
|
+
collectFrameworkNamespaceBindings;
|
|
1420
|
+
|
|
1421
|
+
/** Extract args from a trail() call, handling both two-arg and single-object forms. */
|
|
1422
|
+
const extractTrailArgs = (
|
|
1423
|
+
node: AstNode
|
|
1424
|
+
): { idArg: AstNode | null; configArg: AstNode } | null => {
|
|
1425
|
+
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
1426
|
+
if (!args || args.length === 0) {
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const [firstArg, secondArg] = args;
|
|
1431
|
+
if (!firstArg) {
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Two-arg form: trail('id', { ... })
|
|
1436
|
+
if (secondArg && firstArg.type !== 'ObjectExpression') {
|
|
1437
|
+
return { configArg: secondArg, idArg: firstArg };
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Single-object form: trail({ id: 'x', ... })
|
|
1441
|
+
return firstArg.type === 'ObjectExpression'
|
|
1442
|
+
? { configArg: firstArg, idArg: null }
|
|
1443
|
+
: null;
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
/** Extract the string value from an `id` property inside a config ObjectExpression. */
|
|
1447
|
+
const extractIdFromConfig = (config: AstNode): string | null => {
|
|
1448
|
+
const idProp = findConfigProperty(config, 'id');
|
|
1449
|
+
if (!idProp || !idProp.value) {
|
|
1450
|
+
return null;
|
|
1451
|
+
}
|
|
1452
|
+
return extractStringOrTemplateLiteral(idProp.value as AstNode);
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
const extractTrailId = (trailArgs: {
|
|
1456
|
+
idArg: AstNode | null;
|
|
1457
|
+
configArg: AstNode;
|
|
1458
|
+
}): string | null => {
|
|
1459
|
+
if (trailArgs.idArg) {
|
|
1460
|
+
return extractStringOrTemplateLiteral(trailArgs.idArg);
|
|
1461
|
+
}
|
|
1462
|
+
return extractIdFromConfig(trailArgs.configArg);
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1465
|
+
const extractTrailDefinition = (
|
|
1466
|
+
node: AstNode,
|
|
1467
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext
|
|
1468
|
+
): TrailDefinition | null => {
|
|
1469
|
+
const calleeName = getTrailCalleeName(node, context);
|
|
1470
|
+
if (!calleeName) {
|
|
1471
|
+
return null;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const trailArgs = extractTrailArgs(node);
|
|
1475
|
+
if (!trailArgs) {
|
|
1476
|
+
return null;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const trailId = extractTrailId(trailArgs);
|
|
1480
|
+
if (!trailId) {
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
return {
|
|
1485
|
+
config: trailArgs.configArg,
|
|
1486
|
+
id: trailId,
|
|
1487
|
+
kind: calleeName,
|
|
1488
|
+
start: node.start,
|
|
1489
|
+
};
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
const buildFrameworkNamespaceContext = (
|
|
1493
|
+
ast: AstNode
|
|
1494
|
+
): FrameworkNamespaceContext => {
|
|
1495
|
+
const namespaces = collectFrameworkNamespaceBindings(ast);
|
|
1496
|
+
return {
|
|
1497
|
+
namespaces,
|
|
1498
|
+
safeCallStarts: collectFrameworkNamespacedCallStarts(ast, namespaces),
|
|
1499
|
+
};
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
export const findTrailDefinitions = (ast: AstNode): TrailDefinition[] => {
|
|
1503
|
+
const definitions: TrailDefinition[] = [];
|
|
1504
|
+
const context = buildFrameworkNamespaceContext(ast);
|
|
1505
|
+
|
|
1506
|
+
walk(ast, (node) => {
|
|
1507
|
+
const def = extractTrailDefinition(node, context);
|
|
1508
|
+
if (def) {
|
|
1509
|
+
definitions.push(def);
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
return definitions;
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
// ---------------------------------------------------------------------------
|
|
1517
|
+
// Contour definition extraction
|
|
1518
|
+
// ---------------------------------------------------------------------------
|
|
1519
|
+
|
|
1520
|
+
export interface ContourDefinition {
|
|
1521
|
+
/** Local binding name when the contour is assigned to a variable. */
|
|
1522
|
+
readonly bindingName?: string;
|
|
1523
|
+
/** Contour name string, e.g. "user". */
|
|
1524
|
+
readonly name: string;
|
|
1525
|
+
/** Original call expression for the contour declaration. */
|
|
1526
|
+
readonly call: AstNode;
|
|
1527
|
+
/** Options object argument passed to contour(), when present. */
|
|
1528
|
+
readonly options: AstNode | null;
|
|
1529
|
+
/** Shape object argument passed to contour(). */
|
|
1530
|
+
readonly shape: AstNode;
|
|
1531
|
+
/** Start offset of the call expression. */
|
|
1532
|
+
readonly start: number;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const CONTOUR_PRIMITIVE_NAME = 'contour';
|
|
1536
|
+
|
|
1537
|
+
const matchContourPrimitiveName = (
|
|
1538
|
+
name: string | undefined | null
|
|
1539
|
+
): string | null => (name === CONTOUR_PRIMITIVE_NAME ? name : null);
|
|
1540
|
+
|
|
1541
|
+
const getBareContourCalleeName = (callee: AstNode): string | null => {
|
|
1542
|
+
if (callee.type !== 'Identifier') {
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
return matchContourPrimitiveName(
|
|
1546
|
+
(callee as unknown as { name?: string }).name
|
|
1547
|
+
);
|
|
1548
|
+
};
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Resolve a namespaced `ns.contour(...)` callee to its primitive name. Mirrors
|
|
1552
|
+
* {@link getNamespacedTrailCalleeName}: the receiver identifier must resolve
|
|
1553
|
+
* to an `@ontrails/*` namespace import, and — when a scope-aware
|
|
1554
|
+
* `safeCallStarts` set is provided — the call site must not be shadowed by a
|
|
1555
|
+
* local binding of the same name.
|
|
1556
|
+
*/
|
|
1557
|
+
const getNamespacedContourCalleeName = (
|
|
1558
|
+
callExpr: AstNode,
|
|
1559
|
+
callee: AstNode,
|
|
1560
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext
|
|
1561
|
+
): string | null => {
|
|
1562
|
+
const names = getNamespacedMemberNames(callee);
|
|
1563
|
+
if (!names) {
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
// Unlike the trail/signal variant, contour has no inline-resolution callers
|
|
1567
|
+
// that legitimately invoke this without a FrameworkNamespaceContext, so the
|
|
1568
|
+
// strict namespace gate stays on. If a future caller needs the permissive
|
|
1569
|
+
// fallback, mirror the trail shape and add a regression test first.
|
|
1570
|
+
const ctx = asNamespaceContext(context);
|
|
1571
|
+
if (!ctx || !isNamespacedCallAllowed(callExpr.start, names.receiver, ctx)) {
|
|
1572
|
+
return null;
|
|
1573
|
+
}
|
|
1574
|
+
return matchContourPrimitiveName(names.property);
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Resolve the callee name of a contour call expression. Matches both bare
|
|
1579
|
+
* `contour(...)` identifiers and namespaced `core.contour(...)` callees where
|
|
1580
|
+
* the namespace comes from an `@ontrails/*` import and is unshadowed.
|
|
1581
|
+
*/
|
|
1582
|
+
const getContourCalleeName = (
|
|
1583
|
+
node: AstNode,
|
|
1584
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext
|
|
1585
|
+
): string | null => {
|
|
1586
|
+
if (node.type !== 'CallExpression') {
|
|
1587
|
+
return null;
|
|
1588
|
+
}
|
|
1589
|
+
const callee = node['callee'] as AstNode | undefined;
|
|
1590
|
+
if (!callee) {
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
return (
|
|
1594
|
+
getBareContourCalleeName(callee) ??
|
|
1595
|
+
getNamespacedContourCalleeName(node, callee, context)
|
|
1596
|
+
);
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
const extractContourDefinition = (
|
|
1600
|
+
node: AstNode,
|
|
1601
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext
|
|
1602
|
+
): Omit<ContourDefinition, 'bindingName'> | null => {
|
|
1603
|
+
if (!getContourCalleeName(node, context)) {
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
1608
|
+
const [nameArg, shapeArg, optionsArg] = args ?? [];
|
|
1609
|
+
const name = extractStringLiteral(nameArg);
|
|
1610
|
+
if (!name || shapeArg?.type !== 'ObjectExpression') {
|
|
1611
|
+
return null;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
return {
|
|
1615
|
+
call: node,
|
|
1616
|
+
name,
|
|
1617
|
+
options: optionsArg?.type === 'ObjectExpression' ? optionsArg : null,
|
|
1618
|
+
shape: shapeArg,
|
|
1619
|
+
start: node.start,
|
|
1620
|
+
};
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
const getCallStartFromCandidate = (
|
|
1624
|
+
node: AstNode | undefined
|
|
1625
|
+
): number | null => {
|
|
1626
|
+
if (!node) {
|
|
1627
|
+
return null;
|
|
1628
|
+
}
|
|
1629
|
+
if (node.type === 'CallExpression') {
|
|
1630
|
+
return node.start;
|
|
1631
|
+
}
|
|
1632
|
+
if (node.type !== 'ExpressionStatement') {
|
|
1633
|
+
return null;
|
|
1634
|
+
}
|
|
1635
|
+
const { expression } = node as unknown as { expression?: AstNode };
|
|
1636
|
+
return expression?.type === 'CallExpression' ? expression.start : null;
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
// Statement forms that can directly contain a top-level contour call:
|
|
1640
|
+
// `core.contour(...)` as a bare statement,
|
|
1641
|
+
// `export const ... = core.contour(...)` (handled via VariableDeclarator),
|
|
1642
|
+
// `export default core.contour(...);`.
|
|
1643
|
+
const getCandidateCallHosts = (
|
|
1644
|
+
statement: AstNode
|
|
1645
|
+
): readonly (AstNode | undefined)[] => {
|
|
1646
|
+
if (
|
|
1647
|
+
statement.type !== 'ExportNamedDeclaration' &&
|
|
1648
|
+
statement.type !== 'ExportDefaultDeclaration'
|
|
1649
|
+
) {
|
|
1650
|
+
return [statement];
|
|
1651
|
+
}
|
|
1652
|
+
const { declaration } = statement as unknown as {
|
|
1653
|
+
declaration?: AstNode;
|
|
1654
|
+
};
|
|
1655
|
+
return [statement, declaration];
|
|
1656
|
+
};
|
|
1657
|
+
|
|
1658
|
+
const getTopLevelCallStartsFrom = (statement: AstNode): readonly number[] => {
|
|
1659
|
+
const hosts = getCandidateCallHosts(statement);
|
|
1660
|
+
const starts: number[] = [];
|
|
1661
|
+
for (const host of hosts) {
|
|
1662
|
+
const start = getCallStartFromCandidate(host);
|
|
1663
|
+
if (start !== null) {
|
|
1664
|
+
starts.push(start);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return starts;
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* Collect the `start` offsets of `CallExpression` nodes that appear as
|
|
1672
|
+
* top-level `ExpressionStatement`s in a program body — including inside a
|
|
1673
|
+
* top-level `ExportNamedDeclaration` / `ExportDefaultDeclaration` wrapper.
|
|
1674
|
+
* Used to discriminate top-level statement-form calls from inline nested
|
|
1675
|
+
* calls when `topLevelOnly` is enabled.
|
|
1676
|
+
*/
|
|
1677
|
+
const collectTopLevelStatementCallStarts = (
|
|
1678
|
+
ast: AstNode
|
|
1679
|
+
): ReadonlySet<number> => {
|
|
1680
|
+
const body = (ast as unknown as { body?: readonly AstNode[] }).body ?? [];
|
|
1681
|
+
return new Set(body.flatMap(getTopLevelCallStartsFrom));
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
export interface FindContourDefinitionsOptions {
|
|
1685
|
+
/**
|
|
1686
|
+
* When true, skip contour calls nested inside other expressions (e.g.
|
|
1687
|
+
* `core.contour('inner', {...}).id()` used as a field of an outer contour).
|
|
1688
|
+
* Top-level forms are still surfaced: both `const foo = contour(...)`
|
|
1689
|
+
* declarations and bare `contour('name', {...});` statement-form calls that
|
|
1690
|
+
* appear directly in the program body (optionally wrapped in `export`) are
|
|
1691
|
+
* returned.
|
|
1692
|
+
*
|
|
1693
|
+
* Defaults to `false`: both top-level and inline contours are returned so
|
|
1694
|
+
* that reference-site resolution can reach anonymous inline contours.
|
|
1695
|
+
*/
|
|
1696
|
+
readonly topLevelOnly?: boolean;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* Return every `contour('name', ...)` definition reachable from the AST, in
|
|
1701
|
+
* source order, deduplicated by call-expression start offset.
|
|
1702
|
+
*
|
|
1703
|
+
* Includes both top-level bindings (`const user = contour('user', ...)`) and
|
|
1704
|
+
* inline contour calls nested inside other expressions (e.g.
|
|
1705
|
+
* `contour('outer', { inner: contour('inner', ...).id() })`). Inline contours
|
|
1706
|
+
* carry no `bindingName` because they have no local binding — this asymmetry
|
|
1707
|
+
* is why {@link collectNamedContourIds} returns only the top-level subset
|
|
1708
|
+
* while {@link collectContourDefinitionIds} returns the full set.
|
|
1709
|
+
*
|
|
1710
|
+
* Pass `{ topLevelOnly: true }` via `options` to opt out of inline discovery
|
|
1711
|
+
* without disturbing callers that rely on the default behavior.
|
|
1712
|
+
*
|
|
1713
|
+
* @remarks
|
|
1714
|
+
* Supplying a pre-built `context` skips the second full-AST traversal inside
|
|
1715
|
+
* `buildFrameworkNamespaceContext` — useful for callers (such as
|
|
1716
|
+
* {@link collectContourReferenceSites}) that already built one.
|
|
1717
|
+
*/
|
|
1718
|
+
export const findContourDefinitions = (
|
|
1719
|
+
ast: AstNode,
|
|
1720
|
+
context?: FrameworkNamespaceContext,
|
|
1721
|
+
options?: FindContourDefinitionsOptions
|
|
1722
|
+
): ContourDefinition[] => {
|
|
1723
|
+
const definitions: ContourDefinition[] = [];
|
|
1724
|
+
const seenStarts = new Set<number>();
|
|
1725
|
+
const resolvedContext = context ?? buildFrameworkNamespaceContext(ast);
|
|
1726
|
+
const topLevelOnly = options?.topLevelOnly === true;
|
|
1727
|
+
|
|
1728
|
+
const addContourDefinition = (definition: ContourDefinition): void => {
|
|
1729
|
+
if (seenStarts.has(definition.start)) {
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
definitions.push(definition);
|
|
1734
|
+
seenStarts.add(definition.start);
|
|
1735
|
+
};
|
|
1736
|
+
|
|
1737
|
+
const addNamedContourDefinition = (
|
|
1738
|
+
id: AstNode | undefined,
|
|
1739
|
+
init: AstNode | undefined
|
|
1740
|
+
): void => {
|
|
1741
|
+
if (!init) {
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const definition = extractContourDefinition(init, resolvedContext);
|
|
1746
|
+
if (!definition) {
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
const bindingName = extractBindingName(id);
|
|
1751
|
+
if (bindingName) {
|
|
1752
|
+
addContourDefinition({ ...definition, bindingName });
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
addContourDefinition(definition);
|
|
1757
|
+
};
|
|
1758
|
+
|
|
1759
|
+
// When `topLevelOnly` is set, collect the start offsets of call expressions
|
|
1760
|
+
// that sit directly in the program body as `ExpressionStatement`s (optionally
|
|
1761
|
+
// wrapped in `export`). These are top-level statement-form contour calls and
|
|
1762
|
+
// should still surface alongside `VariableDeclarator` bindings; only calls
|
|
1763
|
+
// nested inside other expressions are excluded.
|
|
1764
|
+
const topLevelStatementCallStarts = topLevelOnly
|
|
1765
|
+
? collectTopLevelStatementCallStarts(ast)
|
|
1766
|
+
: null;
|
|
1767
|
+
|
|
1768
|
+
walk(ast, (node) => {
|
|
1769
|
+
if (node.type === 'VariableDeclarator') {
|
|
1770
|
+
const { id, init } = node as unknown as {
|
|
1771
|
+
readonly id?: AstNode;
|
|
1772
|
+
readonly init?: AstNode;
|
|
1773
|
+
};
|
|
1774
|
+
addNamedContourDefinition(id, init);
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
if (
|
|
1779
|
+
topLevelStatementCallStarts &&
|
|
1780
|
+
!topLevelStatementCallStarts.has(node.start)
|
|
1781
|
+
) {
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
const definition = extractContourDefinition(node, resolvedContext);
|
|
1786
|
+
if (definition) {
|
|
1787
|
+
addContourDefinition(definition);
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
return definitions.toSorted((left, right) => left.start - right.start);
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* Collect the `name` of every contour definition in a parsed file, including
|
|
1796
|
+
* inline contours nested inside other expressions. Returns the same set of
|
|
1797
|
+
* names that {@link findContourDefinitions} discovers under default options.
|
|
1798
|
+
*/
|
|
1799
|
+
export const collectContourDefinitionIds = (
|
|
1800
|
+
ast: AstNode
|
|
1801
|
+
): ReadonlySet<string> =>
|
|
1802
|
+
new Set(findContourDefinitions(ast).map((def) => def.name));
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Collect the `localBinding → contourName` map for `const foo = contour(...)`
|
|
1806
|
+
* declarations. Inline contour calls are intentionally excluded because they
|
|
1807
|
+
* have no local binding — use {@link collectContourDefinitionIds} when the
|
|
1808
|
+
* full set of declared names is required.
|
|
1809
|
+
*/
|
|
1810
|
+
export const collectNamedContourIds = (
|
|
1811
|
+
ast: AstNode
|
|
1812
|
+
): ReadonlyMap<string, string> => {
|
|
1813
|
+
const ids = new Map<string, string>();
|
|
1814
|
+
|
|
1815
|
+
for (const def of findContourDefinitions(ast)) {
|
|
1816
|
+
if (def.bindingName) {
|
|
1817
|
+
ids.set(def.bindingName, def.name);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
return ids;
|
|
1822
|
+
};
|
|
1823
|
+
|
|
1824
|
+
const resolveNamedImportedName = (
|
|
1825
|
+
specifier: AstNode,
|
|
1826
|
+
localName: string
|
|
1827
|
+
): string => {
|
|
1828
|
+
const { imported } = specifier as unknown as { imported?: AstNode };
|
|
1829
|
+
const importedName = imported
|
|
1830
|
+
? (identifierName(imported) ?? extractStringLiteral(imported))
|
|
1831
|
+
: null;
|
|
1832
|
+
return importedName ?? localName;
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
const extractImportSpecifierAlias = (
|
|
1836
|
+
specifier: AstNode
|
|
1837
|
+
): { readonly localName: string; readonly importedName: string } | null => {
|
|
1838
|
+
if (
|
|
1839
|
+
specifier.type !== 'ImportSpecifier' &&
|
|
1840
|
+
specifier.type !== 'ImportDefaultSpecifier'
|
|
1841
|
+
) {
|
|
1842
|
+
return null;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
const { local } = specifier as unknown as { local?: AstNode };
|
|
1846
|
+
const localName = identifierName(local);
|
|
1847
|
+
if (!localName) {
|
|
1848
|
+
return null;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// Default imports bind the default export of the source module to the local
|
|
1852
|
+
// name. We cannot statically recover the exported name without compose-file
|
|
1853
|
+
// analysis, so the local name is the best identifier we have for resolving
|
|
1854
|
+
// against `knownContourIds`. Treat the alias as an identity mapping; the
|
|
1855
|
+
// downstream resolver will fall through to `knownContourIds` on the binding
|
|
1856
|
+
// name and report it as missing when not found.
|
|
1857
|
+
if (specifier.type === 'ImportDefaultSpecifier') {
|
|
1858
|
+
return { importedName: localName, localName };
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
return {
|
|
1862
|
+
importedName: resolveNamedImportedName(specifier, localName),
|
|
1863
|
+
localName,
|
|
1864
|
+
};
|
|
1865
|
+
};
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* Collect `import { foo as bar } from '...'` and `import bar from '...'`
|
|
1869
|
+
* specifier mappings keyed by local binding name. The value is the original
|
|
1870
|
+
* exported name for named imports. Default imports map to themselves because
|
|
1871
|
+
* the exported name cannot be recovered statically — callers should fall
|
|
1872
|
+
* through to `knownContourIds` membership on the local binding name.
|
|
1873
|
+
*/
|
|
1874
|
+
export const collectImportAliasMap = (
|
|
1875
|
+
ast: AstNode
|
|
1876
|
+
): ReadonlyMap<string, string> => {
|
|
1877
|
+
const aliases = new Map<string, string>();
|
|
1878
|
+
|
|
1879
|
+
walk(ast, (node) => {
|
|
1880
|
+
if (node.type !== 'ImportDeclaration') {
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
const specifiers =
|
|
1885
|
+
(node['specifiers'] as readonly AstNode[] | undefined) ?? [];
|
|
1886
|
+
for (const specifier of specifiers) {
|
|
1887
|
+
const alias = extractImportSpecifierAlias(specifier);
|
|
1888
|
+
if (alias) {
|
|
1889
|
+
aliases.set(alias.localName, alias.importedName);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
return aliases;
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
const addUserNamespaceBindingsFromDeclaration = (
|
|
1898
|
+
node: AstNode,
|
|
1899
|
+
into: Set<string>
|
|
1900
|
+
): void => {
|
|
1901
|
+
if (isFrameworkNamespaceSource(getImportSourceValue(node))) {
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
const specifiers =
|
|
1905
|
+
(node['specifiers'] as readonly AstNode[] | undefined) ?? [];
|
|
1906
|
+
for (const specifier of specifiers) {
|
|
1907
|
+
if (specifier.type !== 'ImportNamespaceSpecifier') {
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
const { local } = specifier as unknown as { local?: AstNode };
|
|
1911
|
+
const localName = identifierName(local);
|
|
1912
|
+
if (localName) {
|
|
1913
|
+
into.add(localName);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* Collect local binding names introduced by `import * as <name> from '<src>'`
|
|
1920
|
+
* declarations whose source is NOT an `@ontrails/*` framework package. These
|
|
1921
|
+
* are user-defined namespace imports of contour modules (e.g. `import * as
|
|
1922
|
+
* contours from './contours'`), used to resolve `contours.user` member-access
|
|
1923
|
+
* references to contour ids.
|
|
1924
|
+
*
|
|
1925
|
+
* Framework namespace imports (`import * as core from '@ontrails/core'`) are
|
|
1926
|
+
* intentionally excluded — they carry framework primitives like
|
|
1927
|
+
* `core.contour(...)` and are resolved by {@link buildFrameworkNamespaceContext}.
|
|
1928
|
+
* Mixing them here would treat `core.contour` as a reference to a contour
|
|
1929
|
+
* named "contour", producing false positives.
|
|
1930
|
+
*/
|
|
1931
|
+
export const collectUserNamespaceImportBindings = (
|
|
1932
|
+
ast: AstNode
|
|
1933
|
+
): ReadonlySet<string> => {
|
|
1934
|
+
const bindings = new Set<string>();
|
|
1935
|
+
|
|
1936
|
+
walk(ast, (node) => {
|
|
1937
|
+
if (node.type !== 'ImportDeclaration') {
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
addUserNamespaceBindingsFromDeclaration(node, bindings);
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
return bindings;
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1946
|
+
/**
|
|
1947
|
+
* Resolution context for user-namespace member access like `contours.user`.
|
|
1948
|
+
* Bundles the set of local namespace-binding names (from `import * as x from
|
|
1949
|
+
* './contours'`) with an optional set of proven-safe `MemberExpression` start
|
|
1950
|
+
* offsets from a scope-aware pre-pass. When `safeMemberStarts` is present, a
|
|
1951
|
+
* member access only resolves to a user-namespace target if its start is in
|
|
1952
|
+
* the set — so a function-local shadow of the namespace import does not leak
|
|
1953
|
+
* through. When absent, the name-only gate is used as a
|
|
1954
|
+
* backward-compatible fallback for ad-hoc callers.
|
|
1955
|
+
*/
|
|
1956
|
+
export interface UserNamespaceContext {
|
|
1957
|
+
readonly bindings: ReadonlySet<string>;
|
|
1958
|
+
readonly safeMemberStarts?: ReadonlySet<number>;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/**
|
|
1962
|
+
* Walk the AST with a scope stack and collect `MemberExpression` start offsets
|
|
1963
|
+
* whose receiver is a user-namespace binding that is NOT shadowed by any
|
|
1964
|
+
* enclosing scope. Mirrors `collectFrameworkNamespacedCallStarts` for the
|
|
1965
|
+
* framework-namespace path so `contours.user` inside
|
|
1966
|
+
* `function f(contours) { ... }` is rejected as shadowed.
|
|
1967
|
+
*/
|
|
1968
|
+
/**
|
|
1969
|
+
* Return the receiver-identifier name of a non-computed member access, or
|
|
1970
|
+
* `null` for any other node shape (computed access, non-member, etc.).
|
|
1971
|
+
*/
|
|
1972
|
+
const getNonComputedMemberReceiver = (node: AstNode): string | null => {
|
|
1973
|
+
if (!isMemberAccessNonComputed(node)) {
|
|
1974
|
+
return null;
|
|
1975
|
+
}
|
|
1976
|
+
const { object } = node as unknown as { object?: AstNode };
|
|
1977
|
+
return object ? identifierName(object) : null;
|
|
1978
|
+
};
|
|
1979
|
+
|
|
1980
|
+
const collectUserNamespacedMemberStarts = (
|
|
1981
|
+
ast: AstNode,
|
|
1982
|
+
bindings: ReadonlySet<string>
|
|
1983
|
+
): ReadonlySet<number> => {
|
|
1984
|
+
const starts = new Set<number>();
|
|
1985
|
+
if (bindings.size === 0) {
|
|
1986
|
+
return starts;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
walkWithScopes(ast, (node, scopes) => {
|
|
1990
|
+
const receiver = getNonComputedMemberReceiver(node);
|
|
1991
|
+
if (!receiver || !bindings.has(receiver) || isShadowed(receiver, scopes)) {
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
starts.add(node.start);
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
return starts;
|
|
1998
|
+
};
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Build a {@link UserNamespaceContext} for `ast`, including the scope-aware
|
|
2002
|
+
* `safeMemberStarts` gate. Prefer this over bare
|
|
2003
|
+
* {@link collectUserNamespaceImportBindings} so member access like
|
|
2004
|
+
* `contours.user` is rejected when `contours` is shadowed by a local binding.
|
|
2005
|
+
*/
|
|
2006
|
+
export const buildUserNamespaceContext = (
|
|
2007
|
+
ast: AstNode
|
|
2008
|
+
): UserNamespaceContext => {
|
|
2009
|
+
const bindings = collectUserNamespaceImportBindings(ast);
|
|
2010
|
+
return {
|
|
2011
|
+
bindings,
|
|
2012
|
+
safeMemberStarts: collectUserNamespacedMemberStarts(ast, bindings),
|
|
2013
|
+
};
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
export interface ContourReferenceSite {
|
|
2017
|
+
/** Field on the source contour that declares the reference. */
|
|
2018
|
+
readonly field: string;
|
|
2019
|
+
/** Source contour name. */
|
|
2020
|
+
readonly source: string;
|
|
2021
|
+
/** Start offset of the field declaration. */
|
|
2022
|
+
readonly start: number;
|
|
2023
|
+
/** Target contour name. */
|
|
2024
|
+
readonly target: string;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* Read a property key or member access identifier.
|
|
2029
|
+
*
|
|
2030
|
+
* Returns the identifier name for `Identifier` keys, or the underlying
|
|
2031
|
+
* string literal value for computed access via `['name']` / `"name"`.
|
|
2032
|
+
*/
|
|
2033
|
+
export const getPropertyName = (node: unknown): string | null => {
|
|
2034
|
+
if (typeof node !== 'object' || node === null) {
|
|
2035
|
+
return null;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const { name } = node as { readonly name?: unknown };
|
|
2039
|
+
if (typeof name === 'string') {
|
|
2040
|
+
return name;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
return isAstNode(node) ? extractStringLiteral(node) : null;
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
const stripContourSuffix = (name: string): string => {
|
|
2047
|
+
const suffix = 'Contour';
|
|
2048
|
+
return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
|
|
2049
|
+
};
|
|
2050
|
+
|
|
2051
|
+
const resolveKnownContourName = (
|
|
2052
|
+
name: string,
|
|
2053
|
+
knownContourIds?: ReadonlySet<string>
|
|
2054
|
+
): string | null => {
|
|
2055
|
+
if (knownContourIds?.has(name)) {
|
|
2056
|
+
return name;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// Support the common `const userContour = contour('user', ...)` naming
|
|
2060
|
+
// pattern when callers refer to the binding name instead of the contour ID.
|
|
2061
|
+
// Exact matches always win; suffix stripping is a fallback only.
|
|
2062
|
+
const stripped = stripContourSuffix(name);
|
|
2063
|
+
if (stripped !== name && knownContourIds?.has(stripped)) {
|
|
2064
|
+
return stripped;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
return null;
|
|
2068
|
+
};
|
|
2069
|
+
|
|
2070
|
+
/**
|
|
2071
|
+
* Resolve a local binding name to a contour ID, honoring import aliases.
|
|
2072
|
+
*
|
|
2073
|
+
* Strategies, in order:
|
|
2074
|
+
* 1. Local `const foo = contour('name', ...)` binding → the contour name.
|
|
2075
|
+
* 2. `knownContourIds` membership on the binding name itself (or the
|
|
2076
|
+
* conventional `Contour` suffix strip).
|
|
2077
|
+
* 3. `import { foo as bar }` → use the original exported name `foo`
|
|
2078
|
+
* (and apply strategy 2 / suffix-stripping against it so aliased imports
|
|
2079
|
+
* resolve correctly). If the imported name still isn't recognized, the
|
|
2080
|
+
* imported name is returned so the caller can report it missing.
|
|
2081
|
+
*
|
|
2082
|
+
* Returns `null` only when the name belongs to no known resolution path —
|
|
2083
|
+
* no local binding, no known contour ID, no import, and no suffix match.
|
|
2084
|
+
* Returning `null` means "this identifier is not a contour reference we can
|
|
2085
|
+
* reason about" (e.g. a bare undeclared variable), as opposed to
|
|
2086
|
+
* "a contour reference whose target is missing".
|
|
2087
|
+
*/
|
|
2088
|
+
export const deriveContourIdentifierName = (
|
|
2089
|
+
bindingName: string,
|
|
2090
|
+
namedContourIds: ReadonlyMap<string, string>,
|
|
2091
|
+
knownContourIds?: ReadonlySet<string>,
|
|
2092
|
+
importAliases?: ReadonlyMap<string, string>
|
|
2093
|
+
): string | null => {
|
|
2094
|
+
const localName = namedContourIds.get(bindingName);
|
|
2095
|
+
if (localName) {
|
|
2096
|
+
return localName;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const known = resolveKnownContourName(bindingName, knownContourIds);
|
|
2100
|
+
if (known) {
|
|
2101
|
+
return known;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// If the binding came from an import, use the original exported name as
|
|
2105
|
+
// the resolution target. This lets `import { foo as bar }` resolve to
|
|
2106
|
+
// the exported `foo` rather than the local alias `bar`. If the imported
|
|
2107
|
+
// name still isn't recognized, return it so callers can report it as
|
|
2108
|
+
// missing under its original name.
|
|
2109
|
+
const importedName = importAliases?.get(bindingName);
|
|
2110
|
+
if (importedName) {
|
|
2111
|
+
return (
|
|
2112
|
+
resolveKnownContourName(importedName, knownContourIds) ?? importedName
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
return null;
|
|
2117
|
+
};
|
|
2118
|
+
|
|
2119
|
+
const getContourReferenceMember = (
|
|
2120
|
+
node: AstNode
|
|
2121
|
+
): {
|
|
2122
|
+
readonly object?: AstNode;
|
|
2123
|
+
readonly property?: AstNode;
|
|
2124
|
+
readonly start: number;
|
|
2125
|
+
} | null => {
|
|
2126
|
+
if (
|
|
2127
|
+
node.type !== 'MemberExpression' &&
|
|
2128
|
+
node.type !== 'StaticMemberExpression'
|
|
2129
|
+
) {
|
|
2130
|
+
return null;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
return node as unknown as {
|
|
2134
|
+
readonly object?: AstNode;
|
|
2135
|
+
readonly property?: AstNode;
|
|
2136
|
+
readonly start: number;
|
|
2137
|
+
};
|
|
2138
|
+
};
|
|
2139
|
+
|
|
2140
|
+
const asUserNamespaceContext = (
|
|
2141
|
+
input: ReadonlySet<string> | UserNamespaceContext | undefined
|
|
2142
|
+
): UserNamespaceContext | undefined => {
|
|
2143
|
+
if (!input) {
|
|
2144
|
+
return undefined;
|
|
2145
|
+
}
|
|
2146
|
+
return input instanceof Set
|
|
2147
|
+
? { bindings: input }
|
|
2148
|
+
: (input as UserNamespaceContext);
|
|
2149
|
+
};
|
|
2150
|
+
|
|
2151
|
+
/**
|
|
2152
|
+
* Resolve a user-namespace member access like `contours.user` to its contour
|
|
2153
|
+
* id. Returns the property name (e.g. `'user'`) when the receiver identifier
|
|
2154
|
+
* is a known user-defined namespace binding AND — when the caller provides a
|
|
2155
|
+
* {@link UserNamespaceContext} with `safeMemberStarts` — the member access
|
|
2156
|
+
* site is in that set (i.e. the receiver is not shadowed by any enclosing
|
|
2157
|
+
* scope). Otherwise returns `null`.
|
|
2158
|
+
*
|
|
2159
|
+
* The property name is taken as the contour id verbatim — we cannot statically
|
|
2160
|
+
* resolve what `contours.user` binds to without reading the other file, so we
|
|
2161
|
+
* treat the member name as the candidate target and let
|
|
2162
|
+
* {@link deriveContourIdentifierName}'s downstream `knownContourIds` check
|
|
2163
|
+
* report a missing target.
|
|
2164
|
+
*/
|
|
2165
|
+
export const isUserNamespaceReceiverAllowed = (
|
|
2166
|
+
receiver: string,
|
|
2167
|
+
memberStart: number,
|
|
2168
|
+
ctx: UserNamespaceContext
|
|
2169
|
+
): boolean => {
|
|
2170
|
+
if (!ctx.bindings.has(receiver)) {
|
|
2171
|
+
return false;
|
|
2172
|
+
}
|
|
2173
|
+
// Scope-aware gate: when the pre-pass produced a set, the member access
|
|
2174
|
+
// must appear in it. Without the set, fall back to the bare name check.
|
|
2175
|
+
return ctx.safeMemberStarts ? ctx.safeMemberStarts.has(memberStart) : true;
|
|
2176
|
+
};
|
|
2177
|
+
|
|
2178
|
+
const getContourReferenceTargetFromNamespaceMember = (
|
|
2179
|
+
member: {
|
|
2180
|
+
readonly object?: AstNode;
|
|
2181
|
+
readonly property?: AstNode;
|
|
2182
|
+
readonly start: number;
|
|
2183
|
+
},
|
|
2184
|
+
userNamespace?: ReadonlySet<string> | UserNamespaceContext
|
|
2185
|
+
): string | null => {
|
|
2186
|
+
const ctx = asUserNamespaceContext(userNamespace);
|
|
2187
|
+
if (!ctx || ctx.bindings.size === 0) {
|
|
2188
|
+
return null;
|
|
2189
|
+
}
|
|
2190
|
+
const receiver = member.object ? identifierName(member.object) : null;
|
|
2191
|
+
if (
|
|
2192
|
+
!receiver ||
|
|
2193
|
+
!isUserNamespaceReceiverAllowed(receiver, member.start, ctx)
|
|
2194
|
+
) {
|
|
2195
|
+
return null;
|
|
2196
|
+
}
|
|
2197
|
+
const { property } = member;
|
|
2198
|
+
if (!property || property.type !== 'Identifier') {
|
|
2199
|
+
return null;
|
|
2200
|
+
}
|
|
2201
|
+
return identifierName(property);
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2204
|
+
const getContourReferenceTargetFromObject = (
|
|
2205
|
+
object: AstNode,
|
|
2206
|
+
namedContourIds: ReadonlyMap<string, string>,
|
|
2207
|
+
knownContourIds?: ReadonlySet<string>,
|
|
2208
|
+
importAliases?: ReadonlyMap<string, string>,
|
|
2209
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext,
|
|
2210
|
+
userNamespace?: ReadonlySet<string> | UserNamespaceContext
|
|
2211
|
+
): string | null => {
|
|
2212
|
+
if (object.type === 'Identifier') {
|
|
2213
|
+
const bindingName = identifierName(object);
|
|
2214
|
+
return bindingName
|
|
2215
|
+
? deriveContourIdentifierName(
|
|
2216
|
+
bindingName,
|
|
2217
|
+
namedContourIds,
|
|
2218
|
+
knownContourIds,
|
|
2219
|
+
importAliases
|
|
2220
|
+
)
|
|
2221
|
+
: null;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
const member = getContourReferenceMember(object);
|
|
2225
|
+
if (member) {
|
|
2226
|
+
const namespaceTarget = getContourReferenceTargetFromNamespaceMember(
|
|
2227
|
+
member,
|
|
2228
|
+
userNamespace
|
|
2229
|
+
);
|
|
2230
|
+
if (namespaceTarget) {
|
|
2231
|
+
return namespaceTarget;
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
return extractContourDefinition(object, context)?.name ?? null;
|
|
2236
|
+
};
|
|
2237
|
+
|
|
2238
|
+
const CONTOUR_ID_WRAPPER_METHODS = new Set([
|
|
2239
|
+
'brand',
|
|
2240
|
+
'catch',
|
|
2241
|
+
'default',
|
|
2242
|
+
'describe',
|
|
2243
|
+
'meta',
|
|
2244
|
+
'nullable',
|
|
2245
|
+
'nullish',
|
|
2246
|
+
'optional',
|
|
2247
|
+
'readonly',
|
|
2248
|
+
]);
|
|
2249
|
+
|
|
2250
|
+
const getContourIdCallMember = (
|
|
2251
|
+
node: AstNode
|
|
2252
|
+
): {
|
|
2253
|
+
readonly member: NonNullable<ReturnType<typeof getContourReferenceMember>>;
|
|
2254
|
+
readonly propertyName: string;
|
|
2255
|
+
} | null => {
|
|
2256
|
+
const callee = node['callee'] as AstNode | undefined;
|
|
2257
|
+
const member = callee ? getContourReferenceMember(callee) : null;
|
|
2258
|
+
const propertyName = member ? identifierName(member.property) : null;
|
|
2259
|
+
return member && propertyName ? { member, propertyName } : null;
|
|
2260
|
+
};
|
|
2261
|
+
|
|
2262
|
+
const getContourIdCallObject = function getContourIdCallObject(
|
|
2263
|
+
node: AstNode | undefined
|
|
2264
|
+
): AstNode | null {
|
|
2265
|
+
const current = node;
|
|
2266
|
+
if (!current || current.type !== 'CallExpression') {
|
|
2267
|
+
return null;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
const member = getContourIdCallMember(current);
|
|
2271
|
+
if (!member) {
|
|
2272
|
+
return null;
|
|
2273
|
+
}
|
|
2274
|
+
if (member.propertyName === 'id') {
|
|
2275
|
+
return member.member.object ?? null;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
return CONTOUR_ID_WRAPPER_METHODS.has(member.propertyName)
|
|
2279
|
+
? getContourIdCallObject(member.member.object)
|
|
2280
|
+
: null;
|
|
2281
|
+
};
|
|
2282
|
+
|
|
2283
|
+
const extractContourReferenceTarget = (
|
|
2284
|
+
node: AstNode | undefined,
|
|
2285
|
+
namedContourIds: ReadonlyMap<string, string>,
|
|
2286
|
+
knownContourIds?: ReadonlySet<string>,
|
|
2287
|
+
importAliases?: ReadonlyMap<string, string>,
|
|
2288
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext,
|
|
2289
|
+
userNamespace?: ReadonlySet<string> | UserNamespaceContext
|
|
2290
|
+
): string | null => {
|
|
2291
|
+
const object = getContourIdCallObject(node);
|
|
2292
|
+
return object
|
|
2293
|
+
? getContourReferenceTargetFromObject(
|
|
2294
|
+
object,
|
|
2295
|
+
namedContourIds,
|
|
2296
|
+
knownContourIds,
|
|
2297
|
+
importAliases,
|
|
2298
|
+
context,
|
|
2299
|
+
userNamespace
|
|
2300
|
+
)
|
|
2301
|
+
: null;
|
|
2302
|
+
};
|
|
2303
|
+
|
|
2304
|
+
const getContourShapeProperties = (
|
|
2305
|
+
definition: ContourDefinition
|
|
2306
|
+
): readonly AstNode[] =>
|
|
2307
|
+
(definition.shape['properties'] as readonly AstNode[] | undefined) ?? [];
|
|
2308
|
+
|
|
2309
|
+
const buildContourReferenceSite = (
|
|
2310
|
+
definition: ContourDefinition,
|
|
2311
|
+
property: AstNode,
|
|
2312
|
+
namedContourIds: ReadonlyMap<string, string>,
|
|
2313
|
+
knownContourIds?: ReadonlySet<string>,
|
|
2314
|
+
importAliases?: ReadonlyMap<string, string>,
|
|
2315
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext,
|
|
2316
|
+
userNamespace?: ReadonlySet<string> | UserNamespaceContext
|
|
2317
|
+
): ContourReferenceSite | null => {
|
|
2318
|
+
if (property.type !== 'Property') {
|
|
2319
|
+
return null;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const field = getPropertyName(property.key);
|
|
2323
|
+
const target = extractContourReferenceTarget(
|
|
2324
|
+
property.value as AstNode | undefined,
|
|
2325
|
+
namedContourIds,
|
|
2326
|
+
knownContourIds,
|
|
2327
|
+
importAliases,
|
|
2328
|
+
context,
|
|
2329
|
+
userNamespace
|
|
2330
|
+
);
|
|
2331
|
+
if (!field || !target) {
|
|
2332
|
+
return null;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
return {
|
|
2336
|
+
field,
|
|
2337
|
+
source: definition.name,
|
|
2338
|
+
start: property.start,
|
|
2339
|
+
target,
|
|
2340
|
+
};
|
|
2341
|
+
};
|
|
2342
|
+
|
|
2343
|
+
const findContourReferenceSitesForDefinition = (
|
|
2344
|
+
definition: ContourDefinition,
|
|
2345
|
+
namedContourIds: ReadonlyMap<string, string>,
|
|
2346
|
+
knownContourIds?: ReadonlySet<string>,
|
|
2347
|
+
importAliases?: ReadonlyMap<string, string>,
|
|
2348
|
+
context?: ReadonlySet<string> | FrameworkNamespaceContext,
|
|
2349
|
+
userNamespace?: ReadonlySet<string> | UserNamespaceContext
|
|
2350
|
+
): readonly ContourReferenceSite[] =>
|
|
2351
|
+
getContourShapeProperties(definition).flatMap((property) => {
|
|
2352
|
+
const reference = buildContourReferenceSite(
|
|
2353
|
+
definition,
|
|
2354
|
+
property,
|
|
2355
|
+
namedContourIds,
|
|
2356
|
+
knownContourIds,
|
|
2357
|
+
importAliases,
|
|
2358
|
+
context,
|
|
2359
|
+
userNamespace
|
|
2360
|
+
);
|
|
2361
|
+
return reference ? [reference] : [];
|
|
2362
|
+
});
|
|
2363
|
+
|
|
2364
|
+
/** Collect all contour field references declared via `.id()` in a parsed file. */
|
|
2365
|
+
export const collectContourReferenceSites = (
|
|
2366
|
+
ast: AstNode,
|
|
2367
|
+
knownContourIds?: ReadonlySet<string>
|
|
2368
|
+
): readonly ContourReferenceSite[] => {
|
|
2369
|
+
const namedContourIds = collectNamedContourIds(ast);
|
|
2370
|
+
const importAliases = collectImportAliasMap(ast);
|
|
2371
|
+
const userNamespace = buildUserNamespaceContext(ast);
|
|
2372
|
+
const context = buildFrameworkNamespaceContext(ast);
|
|
2373
|
+
return findContourDefinitions(ast, context).flatMap((definition) =>
|
|
2374
|
+
findContourReferenceSitesForDefinition(
|
|
2375
|
+
definition,
|
|
2376
|
+
namedContourIds,
|
|
2377
|
+
knownContourIds,
|
|
2378
|
+
importAliases,
|
|
2379
|
+
context,
|
|
2380
|
+
userNamespace
|
|
2381
|
+
)
|
|
2382
|
+
);
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
/** Collect contour reference targets keyed by source contour name. */
|
|
2386
|
+
export const collectContourReferenceTargetsByName = (
|
|
2387
|
+
ast: AstNode,
|
|
2388
|
+
knownContourIds?: ReadonlySet<string>
|
|
2389
|
+
): ReadonlyMap<string, readonly string[]> => {
|
|
2390
|
+
const targetsByName = new Map<string, Set<string>>();
|
|
2391
|
+
|
|
2392
|
+
for (const reference of collectContourReferenceSites(ast, knownContourIds)) {
|
|
2393
|
+
const existing = targetsByName.get(reference.source);
|
|
2394
|
+
if (existing) {
|
|
2395
|
+
existing.add(reference.target);
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
targetsByName.set(reference.source, new Set([reference.target]));
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
return new Map(
|
|
2403
|
+
[...targetsByName.entries()].map(([name, targets]) => [name, [...targets]])
|
|
2404
|
+
);
|
|
2405
|
+
};
|
|
2406
|
+
|
|
2407
|
+
// ---------------------------------------------------------------------------
|
|
2408
|
+
// Blaze body extraction
|
|
2409
|
+
// ---------------------------------------------------------------------------
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* Extract top-level `blaze:` property values from an ObjectExpression's direct properties.
|
|
2413
|
+
*
|
|
2414
|
+
* Does not recurse into nested objects, so `meta: { blaze: ... }` is ignored.
|
|
2415
|
+
*/
|
|
2416
|
+
const extractBlazeFromConfig = (config: AstNode): AstNode[] => {
|
|
2417
|
+
const bodies: AstNode[] = [];
|
|
2418
|
+
const properties = config['properties'] as readonly AstNode[] | undefined;
|
|
2419
|
+
if (!properties) {
|
|
2420
|
+
return bodies;
|
|
2421
|
+
}
|
|
2422
|
+
for (const prop of properties) {
|
|
2423
|
+
if (
|
|
2424
|
+
prop.type === 'Property' &&
|
|
2425
|
+
prop.key?.name === 'blaze' &&
|
|
2426
|
+
isAstNode(prop.value)
|
|
2427
|
+
) {
|
|
2428
|
+
bodies.push(prop.value);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
return bodies;
|
|
2432
|
+
};
|
|
2433
|
+
|
|
2434
|
+
/**
|
|
2435
|
+
* Find `blaze:` property values.
|
|
2436
|
+
*
|
|
2437
|
+
* When given an ObjectExpression (trail config), returns only its direct `blaze:`
|
|
2438
|
+
* properties. When given a full AST, finds trail definitions first and extracts
|
|
2439
|
+
* `blaze:` from each config — in both cases ignoring nested `blaze:` properties
|
|
2440
|
+
* (e.g. `meta: { blaze: ... }`).
|
|
2441
|
+
*/
|
|
2442
|
+
export const findBlazeBodies = (node: AstNode): AstNode[] => {
|
|
2443
|
+
if (node.type === 'ObjectExpression') {
|
|
2444
|
+
return extractBlazeFromConfig(node);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// Full AST — find trail definitions and extract blaze from their configs
|
|
2448
|
+
const bodies: AstNode[] = [];
|
|
2449
|
+
for (const def of findTrailDefinitions(node)) {
|
|
2450
|
+
bodies.push(...extractBlazeFromConfig(def.config));
|
|
2451
|
+
}
|
|
2452
|
+
return bodies;
|
|
2453
|
+
};
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* Collect all `signal('id', { ... })` / `signal({ id: 'x', ... })` definition IDs.
|
|
2457
|
+
*
|
|
2458
|
+
* Uses `findTrailDefinitions` under the hood — it already recognizes both
|
|
2459
|
+
* `trail` and `signal` call sites, distinguished by the `kind` field.
|
|
2460
|
+
*/
|
|
2461
|
+
export const collectSignalDefinitionIds = (
|
|
2462
|
+
ast: AstNode
|
|
2463
|
+
): ReadonlySet<string> => {
|
|
2464
|
+
const ids = new Set<string>();
|
|
2465
|
+
for (const def of findTrailDefinitions(ast)) {
|
|
2466
|
+
if (def.kind === 'signal') {
|
|
2467
|
+
ids.add(def.id);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
return ids;
|
|
2471
|
+
};
|
|
2472
|
+
|
|
2473
|
+
const unwrapTopLevelDeclaration = (stmt: AstNode): AstNode => {
|
|
2474
|
+
if (
|
|
2475
|
+
stmt.type === 'ExportNamedDeclaration' ||
|
|
2476
|
+
stmt.type === 'ExportDefaultDeclaration'
|
|
2477
|
+
) {
|
|
2478
|
+
return (stmt as unknown as { declaration?: AstNode }).declaration ?? stmt;
|
|
2479
|
+
}
|
|
2480
|
+
return stmt;
|
|
2481
|
+
};
|
|
2482
|
+
|
|
2483
|
+
const collectSignalIdsFromDeclaration = (
|
|
2484
|
+
declaration: AstNode,
|
|
2485
|
+
context: FrameworkNamespaceContext,
|
|
2486
|
+
ids: Map<string, string>
|
|
2487
|
+
): void => {
|
|
2488
|
+
const declarations =
|
|
2489
|
+
(
|
|
2490
|
+
unwrapTopLevelDeclaration(declaration) as unknown as {
|
|
2491
|
+
declarations?: readonly AstNode[];
|
|
2492
|
+
}
|
|
2493
|
+
).declarations ?? [];
|
|
2494
|
+
|
|
2495
|
+
for (const node of declarations) {
|
|
2496
|
+
const { id, init } = node as unknown as {
|
|
2497
|
+
readonly id?: AstNode;
|
|
2498
|
+
readonly init?: AstNode;
|
|
2499
|
+
};
|
|
2500
|
+
if (!init) {
|
|
2501
|
+
continue;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
const def = extractTrailDefinition(init, context);
|
|
2505
|
+
const name = extractBindingName(id);
|
|
2506
|
+
if (def?.kind === 'signal' && name && !ids.has(name)) {
|
|
2507
|
+
ids.set(name, def.id);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
};
|
|
2511
|
+
|
|
2512
|
+
const collectStringIdsFromDeclaration = (
|
|
2513
|
+
declaration: AstNode,
|
|
2514
|
+
ids: Map<string, string>
|
|
2515
|
+
): void => {
|
|
2516
|
+
const declarations =
|
|
2517
|
+
(
|
|
2518
|
+
unwrapTopLevelDeclaration(declaration) as unknown as {
|
|
2519
|
+
declarations?: readonly AstNode[];
|
|
2520
|
+
}
|
|
2521
|
+
).declarations ?? [];
|
|
2522
|
+
|
|
2523
|
+
for (const node of declarations) {
|
|
2524
|
+
const { id, init } = node as unknown as {
|
|
2525
|
+
readonly id?: AstNode;
|
|
2526
|
+
readonly init?: AstNode;
|
|
2527
|
+
};
|
|
2528
|
+
if (!init) {
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
const name = extractBindingName(id);
|
|
2533
|
+
const value =
|
|
2534
|
+
extractStringLiteral(init) ?? extractPlainTemplateLiteral(init);
|
|
2535
|
+
if (name && value !== null && !ids.has(name)) {
|
|
2536
|
+
ids.set(name, value);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
};
|
|
2540
|
+
|
|
2541
|
+
export type SignalIdentifierResolution =
|
|
2542
|
+
| {
|
|
2543
|
+
readonly id: string;
|
|
2544
|
+
readonly kind: 'signal' | 'string';
|
|
2545
|
+
}
|
|
2546
|
+
| {
|
|
2547
|
+
readonly kind: 'shadowed' | 'unbound';
|
|
2548
|
+
};
|
|
2549
|
+
|
|
2550
|
+
export interface SignalIdentifierResolver {
|
|
2551
|
+
readonly resolve: (reference: AstNode) => SignalIdentifierResolution;
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
interface SignalScopeFrame {
|
|
2555
|
+
readonly bindings: ReadonlySet<string>;
|
|
2556
|
+
readonly end: number;
|
|
2557
|
+
readonly signals: ReadonlyMap<string, string>;
|
|
2558
|
+
readonly start: number;
|
|
2559
|
+
readonly strings: ReadonlyMap<string, string>;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
const collectSignalFrameValues = (
|
|
2563
|
+
node: AstNode,
|
|
2564
|
+
context: FrameworkNamespaceContext
|
|
2565
|
+
): {
|
|
2566
|
+
readonly signals: ReadonlyMap<string, string>;
|
|
2567
|
+
readonly strings: ReadonlyMap<string, string>;
|
|
2568
|
+
} => {
|
|
2569
|
+
const signals = new Map<string, string>();
|
|
2570
|
+
const strings = new Map<string, string>();
|
|
2571
|
+
|
|
2572
|
+
const collectDeclaration = (statement: AstNode): void => {
|
|
2573
|
+
const declaration = unwrapTopLevelDeclaration(statement);
|
|
2574
|
+
if (declaration.type !== 'VariableDeclaration') {
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
collectSignalIdsFromDeclaration(declaration, context, signals);
|
|
2578
|
+
collectStringIdsFromDeclaration(declaration, strings);
|
|
2579
|
+
};
|
|
2580
|
+
|
|
2581
|
+
if (
|
|
2582
|
+
node.type === 'Program' ||
|
|
2583
|
+
node.type === 'BlockStatement' ||
|
|
2584
|
+
node.type === 'FunctionBody'
|
|
2585
|
+
) {
|
|
2586
|
+
const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
|
|
2587
|
+
for (const statement of body) {
|
|
2588
|
+
collectDeclaration(statement);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
if (node.type === 'ForStatement') {
|
|
2593
|
+
const { init } = node as unknown as { init?: AstNode };
|
|
2594
|
+
if (init) {
|
|
2595
|
+
collectDeclaration(init);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
if (node.type === 'SwitchStatement') {
|
|
2600
|
+
const cases =
|
|
2601
|
+
(node as unknown as { cases?: readonly AstNode[] }).cases ?? [];
|
|
2602
|
+
for (const item of cases) {
|
|
2603
|
+
const consequent =
|
|
2604
|
+
(item as unknown as { consequent?: readonly AstNode[] }).consequent ??
|
|
2605
|
+
[];
|
|
2606
|
+
for (const statement of consequent) {
|
|
2607
|
+
collectDeclaration(statement);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
return { signals, strings };
|
|
2613
|
+
};
|
|
2614
|
+
|
|
2615
|
+
const collectSignalScopeFrames = (
|
|
2616
|
+
ast: AstNode,
|
|
2617
|
+
context: FrameworkNamespaceContext
|
|
2618
|
+
): readonly SignalScopeFrame[] => {
|
|
2619
|
+
const frames: SignalScopeFrame[] = [];
|
|
2620
|
+
|
|
2621
|
+
walk(ast, (node) => {
|
|
2622
|
+
if (!(node.type in SCOPE_FRAME_COLLECTORS)) {
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
const values = collectSignalFrameValues(node, context);
|
|
2626
|
+
frames.push({
|
|
2627
|
+
bindings: collectScopeFrameBindings(node),
|
|
2628
|
+
end: node.end,
|
|
2629
|
+
signals: values.signals,
|
|
2630
|
+
start: node.start,
|
|
2631
|
+
strings: values.strings,
|
|
2632
|
+
});
|
|
2633
|
+
});
|
|
2634
|
+
|
|
2635
|
+
return frames;
|
|
2636
|
+
};
|
|
2637
|
+
|
|
2638
|
+
const isInsideFrame = (reference: AstNode, frame: SignalScopeFrame): boolean =>
|
|
2639
|
+
frame.start <= reference.start && reference.end <= frame.end;
|
|
2640
|
+
|
|
2641
|
+
const compareInnermostFrame = (
|
|
2642
|
+
a: SignalScopeFrame,
|
|
2643
|
+
b: SignalScopeFrame
|
|
2644
|
+
): number => {
|
|
2645
|
+
const aSize = a.end - a.start;
|
|
2646
|
+
const bSize = b.end - b.start;
|
|
2647
|
+
return aSize - bSize || b.start - a.start;
|
|
2648
|
+
};
|
|
2649
|
+
|
|
2650
|
+
export const buildSignalIdentifierResolver = (
|
|
2651
|
+
ast: AstNode
|
|
2652
|
+
): SignalIdentifierResolver => {
|
|
2653
|
+
const context = buildFrameworkNamespaceContext(ast);
|
|
2654
|
+
const frames = collectSignalScopeFrames(ast, context);
|
|
2655
|
+
|
|
2656
|
+
return {
|
|
2657
|
+
resolve(reference: AstNode): SignalIdentifierResolution {
|
|
2658
|
+
const name = identifierName(reference);
|
|
2659
|
+
if (!name) {
|
|
2660
|
+
return { kind: 'unbound' };
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
const containingFrames = frames
|
|
2664
|
+
.filter((frame) => isInsideFrame(reference, frame))
|
|
2665
|
+
.toSorted(compareInnermostFrame);
|
|
2666
|
+
|
|
2667
|
+
for (const frame of containingFrames) {
|
|
2668
|
+
if (!frame.bindings.has(name)) {
|
|
2669
|
+
continue;
|
|
2670
|
+
}
|
|
2671
|
+
const signalId = frame.signals.get(name);
|
|
2672
|
+
if (signalId) {
|
|
2673
|
+
return { id: signalId, kind: 'signal' };
|
|
2674
|
+
}
|
|
2675
|
+
const stringId = frame.strings.get(name);
|
|
2676
|
+
if (stringId) {
|
|
2677
|
+
return { id: stringId, kind: 'string' };
|
|
2678
|
+
}
|
|
2679
|
+
return { kind: 'shadowed' };
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
return { kind: 'unbound' };
|
|
2683
|
+
},
|
|
2684
|
+
};
|
|
2685
|
+
};
|
|
2686
|
+
|
|
2687
|
+
/** Collect `const foo = trail('id', ...)` bindings from a parsed file. */
|
|
2688
|
+
export const collectNamedTrailIds = (
|
|
2689
|
+
ast: AstNode
|
|
2690
|
+
): ReadonlyMap<string, string> => {
|
|
2691
|
+
const ids = new Map<string, string>();
|
|
2692
|
+
const context = buildFrameworkNamespaceContext(ast);
|
|
2693
|
+
|
|
2694
|
+
walk(ast, (node) => {
|
|
2695
|
+
if (node.type !== 'VariableDeclarator') {
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
const { id, init } = node as unknown as {
|
|
2700
|
+
readonly id?: AstNode;
|
|
2701
|
+
readonly init?: AstNode;
|
|
2702
|
+
};
|
|
2703
|
+
if (!init) {
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
const def = extractTrailDefinition(init, context);
|
|
2708
|
+
const name = extractBindingName(id);
|
|
2709
|
+
if (def?.kind === 'trail' && name) {
|
|
2710
|
+
ids.set(name, def.id);
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2714
|
+
return ids;
|
|
2715
|
+
};
|
|
2716
|
+
|
|
2717
|
+
/** Extract the raw `composes: [...]` array elements from a trail config. */
|
|
2718
|
+
export const getComposeElements = (config: AstNode): readonly AstNode[] => {
|
|
2719
|
+
const composesProp = findConfigProperty(config, 'composes');
|
|
2720
|
+
if (!composesProp) {
|
|
2721
|
+
return [];
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
const arrayNode = composesProp.value;
|
|
2725
|
+
if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
|
|
2726
|
+
return [];
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
const elements = (arrayNode as AstNode)['elements'] as
|
|
2730
|
+
| readonly AstNode[]
|
|
2731
|
+
| undefined;
|
|
2732
|
+
return elements ?? [];
|
|
2733
|
+
};
|
|
2734
|
+
|
|
2735
|
+
/**
|
|
2736
|
+
* Resolve a single `composes: [...]` element to its target trail ID.
|
|
2737
|
+
*
|
|
2738
|
+
* Handles string literals, identifier references (via `namedTrailIds` map or
|
|
2739
|
+
* `const NAME = '...'` resolution), and inline `trail(...)` call expressions.
|
|
2740
|
+
*/
|
|
2741
|
+
export const deriveComposeElementId = (
|
|
2742
|
+
element: AstNode,
|
|
2743
|
+
sourceCode: string,
|
|
2744
|
+
namedTrailIds: ReadonlyMap<string, string>
|
|
2745
|
+
): string | null => {
|
|
2746
|
+
if (isStringLiteral(element)) {
|
|
2747
|
+
return getStringValue(element);
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
if (element.type === 'Identifier') {
|
|
2751
|
+
const name = identifierName(element);
|
|
2752
|
+
return name
|
|
2753
|
+
? (namedTrailIds.get(name) ?? deriveConstString(name, sourceCode))
|
|
2754
|
+
: null;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
const inlineDef = extractTrailDefinition(element);
|
|
2758
|
+
return inlineDef?.kind === 'trail' ? inlineDef.id : null;
|
|
2759
|
+
};
|
|
2760
|
+
|
|
2761
|
+
/**
|
|
2762
|
+
* Collect all trail IDs referenced by a single trail definition's
|
|
2763
|
+
* `composes: [...]` array, deduplicated.
|
|
2764
|
+
*/
|
|
2765
|
+
export const extractDefinitionComposeTargetIds = (
|
|
2766
|
+
config: AstNode,
|
|
2767
|
+
sourceCode: string,
|
|
2768
|
+
namedTrailIds: ReadonlyMap<string, string>
|
|
2769
|
+
): readonly string[] => [
|
|
2770
|
+
...new Set(
|
|
2771
|
+
getComposeElements(config).flatMap((element) => {
|
|
2772
|
+
const id = deriveComposeElementId(element, sourceCode, namedTrailIds);
|
|
2773
|
+
return id ? [id] : [];
|
|
2774
|
+
})
|
|
2775
|
+
),
|
|
2776
|
+
];
|
|
2777
|
+
|
|
2778
|
+
/** Collect all trail IDs referenced by declared `composes: [...]` arrays. */
|
|
2779
|
+
export const collectComposeTargetTrailIds = (
|
|
2780
|
+
ast: AstNode,
|
|
2781
|
+
sourceCode: string
|
|
2782
|
+
): ReadonlySet<string> => {
|
|
2783
|
+
const ids = new Set<string>();
|
|
2784
|
+
const namedTrailIds = collectNamedTrailIds(ast);
|
|
2785
|
+
|
|
2786
|
+
for (const def of findTrailDefinitions(ast)) {
|
|
2787
|
+
if (def.kind !== 'trail') {
|
|
2788
|
+
continue;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
for (const id of extractDefinitionComposeTargetIds(
|
|
2792
|
+
def.config,
|
|
2793
|
+
sourceCode,
|
|
2794
|
+
namedTrailIds
|
|
2795
|
+
)) {
|
|
2796
|
+
ids.add(id);
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
return ids;
|
|
2801
|
+
};
|
|
2802
|
+
|
|
2803
|
+
const INTENT_VALUE_SET = new Set<string>(intentValues);
|
|
2804
|
+
const DEFAULT_INTENT: Intent = 'write';
|
|
2805
|
+
|
|
2806
|
+
const normalizeTrailIntent = (value: string): Intent =>
|
|
2807
|
+
INTENT_VALUE_SET.has(value) ? (value as Intent) : DEFAULT_INTENT;
|
|
2808
|
+
|
|
2809
|
+
const extractTrailIntent = (config: AstNode): Intent => {
|
|
2810
|
+
const intentProp = findConfigProperty(config, 'intent');
|
|
2811
|
+
if (!intentProp || !isStringLiteral(intentProp.value as AstNode)) {
|
|
2812
|
+
return DEFAULT_INTENT;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
const value = getStringValue(intentProp.value as AstNode);
|
|
2816
|
+
return value ? normalizeTrailIntent(value) : DEFAULT_INTENT;
|
|
2817
|
+
};
|
|
2818
|
+
|
|
2819
|
+
/** Collect the normalized intent for every trail definition in a parsed file. */
|
|
2820
|
+
export const collectTrailIntentsById = (
|
|
2821
|
+
ast: AstNode
|
|
2822
|
+
): ReadonlyMap<string, Intent> => {
|
|
2823
|
+
const intents = new Map<string, Intent>();
|
|
2824
|
+
|
|
2825
|
+
for (const def of findTrailDefinitions(ast)) {
|
|
2826
|
+
if (def.kind === 'trail') {
|
|
2827
|
+
intents.set(def.id, extractTrailIntent(def.config));
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
return intents;
|
|
2832
|
+
};
|
|
2833
|
+
|
|
2834
|
+
// ---------------------------------------------------------------------------
|
|
2835
|
+
// Store / factory pattern extraction
|
|
2836
|
+
// ---------------------------------------------------------------------------
|
|
2837
|
+
|
|
2838
|
+
export interface StoreTableDefinition {
|
|
2839
|
+
/** Table name declared inside store({ ... }). */
|
|
2840
|
+
readonly name: string;
|
|
2841
|
+
/**
|
|
2842
|
+
* Local binding name of the enclosing `store(...)` declaration, if the
|
|
2843
|
+
* `store(...)` call is bound to a `const`/`let`/`var` (e.g. `db` in
|
|
2844
|
+
* `const db = store({ ... })`). Null for anonymous stores.
|
|
2845
|
+
*/
|
|
2846
|
+
readonly storeBinding: string | null;
|
|
2847
|
+
/**
|
|
2848
|
+
* Stable composite key for this table in the form `${storeBinding}:${name}`,
|
|
2849
|
+
* falling back to the bare `name` when the store is anonymous. Use this for
|
|
2850
|
+
* compose-rule / compose-file keying so two stores with the same table name
|
|
2851
|
+
* never collide.
|
|
2852
|
+
*/
|
|
2853
|
+
readonly key: string;
|
|
2854
|
+
/** Start offset of the table property declaration. */
|
|
2855
|
+
readonly start: number;
|
|
2856
|
+
/** Whether the authored table opts into version tracking. */
|
|
2857
|
+
readonly versioned: boolean;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
/**
|
|
2861
|
+
* Build a composite key for a store table: `${storeBinding}:${tableName}`,
|
|
2862
|
+
* falling back to the bare `tableName` when the enclosing store has no local
|
|
2863
|
+
* binding. Centralized so rule keying stays stable.
|
|
2864
|
+
*
|
|
2865
|
+
* @remarks
|
|
2866
|
+
* The key is intentionally file-local (no module path prefix). Compose-file
|
|
2867
|
+
* aggregation in `ProjectContext` merges keys from all files, so two files
|
|
2868
|
+
* with `const db = store({ notes: ... })` both produce `db:notes` — this is
|
|
2869
|
+
* the desired behavior because the warden checks for *pattern completeness*
|
|
2870
|
+
* across the project and matching keys signals that the same logical table
|
|
2871
|
+
* is covered. If two genuinely different tables share a binding and name,
|
|
2872
|
+
* that is a code-level naming collision the developer should resolve.
|
|
2873
|
+
*/
|
|
2874
|
+
export const makeStoreTableKey = (
|
|
2875
|
+
storeBinding: string | null,
|
|
2876
|
+
tableName: string
|
|
2877
|
+
): string => (storeBinding ? `${storeBinding}:${tableName}` : tableName);
|
|
2878
|
+
|
|
2879
|
+
const isBooleanLiteral = (node: AstNode | undefined): boolean =>
|
|
2880
|
+
Boolean(
|
|
2881
|
+
node &&
|
|
2882
|
+
((node.type === 'BooleanLiteral' &&
|
|
2883
|
+
(node as unknown as { value?: unknown }).value === true) ||
|
|
2884
|
+
(node.type === 'Literal' &&
|
|
2885
|
+
(node as unknown as { value?: unknown }).value === true))
|
|
2886
|
+
);
|
|
2887
|
+
|
|
2888
|
+
/**
|
|
2889
|
+
* Check if a node is a `CallExpression` to the identifier `name`.
|
|
2890
|
+
*
|
|
2891
|
+
* e.g. `isNamedCall(node, 'store')` matches `store({...})` but not
|
|
2892
|
+
* `someObj.store()` or `storeAlt()`.
|
|
2893
|
+
*/
|
|
2894
|
+
export const isNamedCall = (node: AstNode | undefined, name: string): boolean =>
|
|
2895
|
+
!!node &&
|
|
2896
|
+
node.type === 'CallExpression' &&
|
|
2897
|
+
identifierName((node as unknown as { callee?: AstNode }).callee) === name;
|
|
2898
|
+
|
|
2899
|
+
/**
|
|
2900
|
+
* Narrow a member-expression node (`a.b` or `a['b']`) to its `object` /
|
|
2901
|
+
* `property` pair, returning `null` for anything else.
|
|
2902
|
+
*/
|
|
2903
|
+
export const getMemberExpression = (
|
|
2904
|
+
node: AstNode | undefined
|
|
2905
|
+
): { readonly object?: AstNode; readonly property?: AstNode } | null => {
|
|
2906
|
+
if (
|
|
2907
|
+
!node ||
|
|
2908
|
+
(node.type !== 'MemberExpression' && node.type !== 'StaticMemberExpression')
|
|
2909
|
+
) {
|
|
2910
|
+
return null;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
return node as unknown as {
|
|
2914
|
+
readonly object?: AstNode;
|
|
2915
|
+
readonly property?: AstNode;
|
|
2916
|
+
};
|
|
2917
|
+
};
|
|
2918
|
+
|
|
2919
|
+
/**
|
|
2920
|
+
* Resolve a `<store>.tables.<name>` member expression to its store binding
|
|
2921
|
+
* and table name.
|
|
2922
|
+
*
|
|
2923
|
+
* Returns `null` for anything that isn't a two-level member access ending in
|
|
2924
|
+
* `.tables.<name>`. The store binding is the identifier of the object owning
|
|
2925
|
+
* `.tables` — typically the local binding from `const db = store(...)`.
|
|
2926
|
+
*/
|
|
2927
|
+
export const extractStoreTableFromMember = (
|
|
2928
|
+
node: AstNode | undefined
|
|
2929
|
+
): {
|
|
2930
|
+
readonly storeBinding: string | null;
|
|
2931
|
+
readonly tableName: string;
|
|
2932
|
+
} | null => {
|
|
2933
|
+
const member = getMemberExpression(node);
|
|
2934
|
+
const tableName = member ? getPropertyName(member.property) : null;
|
|
2935
|
+
const tablesMember = member ? getMemberExpression(member.object) : null;
|
|
2936
|
+
if (!tableName || !tablesMember) {
|
|
2937
|
+
return null;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
if (getPropertyName(tablesMember.property) !== 'tables') {
|
|
2941
|
+
return null;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
const storeBinding = identifierName(tablesMember.object) ?? null;
|
|
2945
|
+
return { storeBinding, tableName };
|
|
2946
|
+
};
|
|
2947
|
+
|
|
2948
|
+
/**
|
|
2949
|
+
* Collect `const foo = <store>.tables.<name>` bindings from a parsed file,
|
|
2950
|
+
* keyed by the local binding name. Values are the composite table key
|
|
2951
|
+
* (`${storeBinding}:${tableName}`) so callers can dedupe across stores that
|
|
2952
|
+
* share a table name.
|
|
2953
|
+
*/
|
|
2954
|
+
export const collectNamedStoreTableIds = (
|
|
2955
|
+
ast: AstNode
|
|
2956
|
+
): ReadonlyMap<string, string> => {
|
|
2957
|
+
const ids = new Map<string, string>();
|
|
2958
|
+
|
|
2959
|
+
walk(ast, (node) => {
|
|
2960
|
+
if (node.type !== 'VariableDeclarator') {
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
const { id, init } = node as unknown as {
|
|
2965
|
+
readonly id?: AstNode;
|
|
2966
|
+
readonly init?: AstNode;
|
|
2967
|
+
};
|
|
2968
|
+
const name = extractBindingName(id);
|
|
2969
|
+
const table = extractStoreTableFromMember(init);
|
|
2970
|
+
if (name && table) {
|
|
2971
|
+
ids.set(name, makeStoreTableKey(table.storeBinding, table.tableName));
|
|
2972
|
+
}
|
|
2973
|
+
});
|
|
2974
|
+
|
|
2975
|
+
return ids;
|
|
2976
|
+
};
|
|
2977
|
+
|
|
2978
|
+
/**
|
|
2979
|
+
* Resolve an argument node to a composite store-table key
|
|
2980
|
+
* (`${storeBinding}:${tableName}` or bare `tableName` when anonymous).
|
|
2981
|
+
*
|
|
2982
|
+
* Handles the two authoring patterns:
|
|
2983
|
+
* - direct member access: `db.tables.notes`
|
|
2984
|
+
* - identifier reference: `const notesTable = db.tables.notes; crud(notesTable, …)`
|
|
2985
|
+
*/
|
|
2986
|
+
export const deriveStoreTableId = (
|
|
2987
|
+
node: AstNode | undefined,
|
|
2988
|
+
namedStoreTableIds: ReadonlyMap<string, string>
|
|
2989
|
+
): string | null => {
|
|
2990
|
+
if (!node) {
|
|
2991
|
+
return null;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
if (node.type === 'Identifier') {
|
|
2995
|
+
const name = identifierName(node);
|
|
2996
|
+
return name ? (namedStoreTableIds.get(name) ?? null) : null;
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
const member = extractStoreTableFromMember(node);
|
|
3000
|
+
return member
|
|
3001
|
+
? makeStoreTableKey(member.storeBinding, member.tableName)
|
|
3002
|
+
: null;
|
|
3003
|
+
};
|
|
3004
|
+
|
|
3005
|
+
const extractStoreTableDefinitions = (
|
|
3006
|
+
node: AstNode,
|
|
3007
|
+
storeBinding: string | null
|
|
3008
|
+
): readonly StoreTableDefinition[] => {
|
|
3009
|
+
if (!isNamedCall(node, 'store')) {
|
|
3010
|
+
return [];
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
const [tablesArg] = ((node as unknown as { arguments?: readonly AstNode[] })
|
|
3014
|
+
.arguments ?? []) as readonly AstNode[];
|
|
3015
|
+
if (!tablesArg || tablesArg.type !== 'ObjectExpression') {
|
|
3016
|
+
return [];
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
const properties = tablesArg['properties'] as readonly AstNode[] | undefined;
|
|
3020
|
+
if (!properties) {
|
|
3021
|
+
return [];
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
return properties.flatMap((property) => {
|
|
3025
|
+
if (property.type !== 'Property') {
|
|
3026
|
+
return [];
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
const name = getPropertyName(property.key);
|
|
3030
|
+
const value = property.value as AstNode | undefined;
|
|
3031
|
+
if (!name || value?.type !== 'ObjectExpression') {
|
|
3032
|
+
return [];
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
const versionedProp = findConfigProperty(value, 'versioned');
|
|
3036
|
+
return [
|
|
3037
|
+
{
|
|
3038
|
+
key: makeStoreTableKey(storeBinding, name),
|
|
3039
|
+
name,
|
|
3040
|
+
start: property.start,
|
|
3041
|
+
storeBinding,
|
|
3042
|
+
versioned: isBooleanLiteral(
|
|
3043
|
+
versionedProp?.value as AstNode | undefined
|
|
3044
|
+
),
|
|
3045
|
+
},
|
|
3046
|
+
];
|
|
3047
|
+
});
|
|
3048
|
+
};
|
|
3049
|
+
|
|
3050
|
+
export const findStoreTableDefinitions = (
|
|
3051
|
+
ast: AstNode
|
|
3052
|
+
): readonly StoreTableDefinition[] => {
|
|
3053
|
+
const definitions: StoreTableDefinition[] = [];
|
|
3054
|
+
const seenStoreCalls = new WeakSet<AstNode>();
|
|
3055
|
+
|
|
3056
|
+
// First pass: bound stores (walk VariableDeclarators so we know the binding).
|
|
3057
|
+
walk(ast, (node) => {
|
|
3058
|
+
if (node.type !== 'VariableDeclarator') {
|
|
3059
|
+
return;
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
const { id, init } = node as unknown as {
|
|
3063
|
+
readonly id?: AstNode;
|
|
3064
|
+
readonly init?: AstNode;
|
|
3065
|
+
};
|
|
3066
|
+
if (!init || !isNamedCall(init, 'store')) {
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
seenStoreCalls.add(init);
|
|
3071
|
+
const storeBinding = extractBindingName(id);
|
|
3072
|
+
definitions.push(...extractStoreTableDefinitions(init, storeBinding));
|
|
3073
|
+
});
|
|
3074
|
+
|
|
3075
|
+
// Second pass: anonymous `store({...})` calls not bound to a variable
|
|
3076
|
+
// (e.g. an inline default export). Use the bare table name as the key.
|
|
3077
|
+
walk(ast, (node) => {
|
|
3078
|
+
if (!isNamedCall(node, 'store') || seenStoreCalls.has(node)) {
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
definitions.push(...extractStoreTableDefinitions(node, null));
|
|
3082
|
+
});
|
|
3083
|
+
|
|
3084
|
+
return definitions;
|
|
3085
|
+
};
|
|
3086
|
+
|
|
3087
|
+
export const collectCrudTableIds = (ast: AstNode): ReadonlySet<string> => {
|
|
3088
|
+
const ids = new Set<string>();
|
|
3089
|
+
const namedStoreTableIds = collectNamedStoreTableIds(ast);
|
|
3090
|
+
|
|
3091
|
+
walk(ast, (node) => {
|
|
3092
|
+
if (!isNamedCall(node, 'crud')) {
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
const [tableArg] = ((node as unknown as { arguments?: readonly AstNode[] })
|
|
3097
|
+
.arguments ?? []) as readonly AstNode[];
|
|
3098
|
+
const tableId = deriveStoreTableId(tableArg, namedStoreTableIds);
|
|
3099
|
+
if (tableId) {
|
|
3100
|
+
ids.add(tableId);
|
|
3101
|
+
}
|
|
3102
|
+
});
|
|
3103
|
+
|
|
3104
|
+
return ids;
|
|
3105
|
+
};
|
|
3106
|
+
|
|
3107
|
+
export const collectReconcileTableIds = (ast: AstNode): ReadonlySet<string> => {
|
|
3108
|
+
const ids = new Set<string>();
|
|
3109
|
+
const namedStoreTableIds = collectNamedStoreTableIds(ast);
|
|
3110
|
+
|
|
3111
|
+
walk(ast, (node) => {
|
|
3112
|
+
if (!isNamedCall(node, 'reconcile')) {
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
const [configArg] = ((
|
|
3117
|
+
node as unknown as {
|
|
3118
|
+
arguments?: readonly AstNode[];
|
|
3119
|
+
}
|
|
3120
|
+
).arguments ?? []) as readonly AstNode[];
|
|
3121
|
+
if (!configArg || configArg.type !== 'ObjectExpression') {
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
const tableProp = findConfigProperty(configArg, 'table');
|
|
3126
|
+
const tableId = deriveStoreTableId(
|
|
3127
|
+
tableProp?.value as AstNode | undefined,
|
|
3128
|
+
namedStoreTableIds
|
|
3129
|
+
);
|
|
3130
|
+
if (tableId) {
|
|
3131
|
+
ids.add(tableId);
|
|
3132
|
+
}
|
|
3133
|
+
});
|
|
3134
|
+
|
|
3135
|
+
return ids;
|
|
3136
|
+
};
|
|
3137
|
+
|
|
3138
|
+
const STORE_SIGNAL_OPERATIONS = new Set(['created', 'removed', 'updated']);
|
|
3139
|
+
|
|
3140
|
+
const extractStoreSignalIdFromMember = (
|
|
3141
|
+
node: AstNode | undefined,
|
|
3142
|
+
namedStoreTableIds: ReadonlyMap<string, string>
|
|
3143
|
+
): string | null => {
|
|
3144
|
+
const member = getMemberExpression(node);
|
|
3145
|
+
const operation = member ? getPropertyName(member.property) : null;
|
|
3146
|
+
if (!operation || !STORE_SIGNAL_OPERATIONS.has(operation)) {
|
|
3147
|
+
return null;
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
const signalsMember = member ? getMemberExpression(member.object) : null;
|
|
3151
|
+
if (!signalsMember || getPropertyName(signalsMember.property) !== 'signals') {
|
|
3152
|
+
return null;
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
const tableId = deriveStoreTableId(signalsMember.object, namedStoreTableIds);
|
|
3156
|
+
return tableId ? `${tableId}.${operation}` : null;
|
|
3157
|
+
};
|
|
3158
|
+
|
|
3159
|
+
const collectNamedStoreSignalIds = (
|
|
3160
|
+
ast: AstNode,
|
|
3161
|
+
namedStoreTableIds: ReadonlyMap<string, string>
|
|
3162
|
+
): ReadonlyMap<string, string> => {
|
|
3163
|
+
const ids = new Map<string, string>();
|
|
3164
|
+
|
|
3165
|
+
walk(ast, (node) => {
|
|
3166
|
+
if (node.type !== 'VariableDeclarator') {
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
const { id, init } = node as unknown as {
|
|
3171
|
+
readonly id?: AstNode;
|
|
3172
|
+
readonly init?: AstNode;
|
|
3173
|
+
};
|
|
3174
|
+
const name = extractBindingName(id);
|
|
3175
|
+
const signalId = extractStoreSignalIdFromMember(init, namedStoreTableIds);
|
|
3176
|
+
if (name && signalId) {
|
|
3177
|
+
ids.set(name, signalId);
|
|
3178
|
+
}
|
|
3179
|
+
});
|
|
3180
|
+
|
|
3181
|
+
return ids;
|
|
3182
|
+
};
|
|
3183
|
+
|
|
3184
|
+
const getOnElements = (config: AstNode): readonly AstNode[] => {
|
|
3185
|
+
const onProp = findConfigProperty(config, 'on');
|
|
3186
|
+
if (!onProp) {
|
|
3187
|
+
return [];
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
const arrayNode = onProp.value;
|
|
3191
|
+
if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
|
|
3192
|
+
return [];
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
const elements = (arrayNode as AstNode)['elements'] as
|
|
3196
|
+
| readonly AstNode[]
|
|
3197
|
+
| undefined;
|
|
3198
|
+
return elements ?? [];
|
|
3199
|
+
};
|
|
3200
|
+
|
|
3201
|
+
const resolveNamedOnSignalId = (
|
|
3202
|
+
element: AstNode,
|
|
3203
|
+
sourceCode: string,
|
|
3204
|
+
namedStoreSignalIds: ReadonlyMap<string, string>
|
|
3205
|
+
): string | null => {
|
|
3206
|
+
if (element.type !== 'Identifier') {
|
|
3207
|
+
return null;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
const name = identifierName(element);
|
|
3211
|
+
return name
|
|
3212
|
+
? (namedStoreSignalIds.get(name) ?? deriveConstString(name, sourceCode))
|
|
3213
|
+
: null;
|
|
3214
|
+
};
|
|
3215
|
+
|
|
3216
|
+
const resolveInlineOnSignalId = (element: AstNode): string | null => {
|
|
3217
|
+
const definition = extractTrailDefinition(element);
|
|
3218
|
+
return definition?.kind === 'signal' ? definition.id : null;
|
|
3219
|
+
};
|
|
3220
|
+
|
|
3221
|
+
const resolveOnElementSignalId = (
|
|
3222
|
+
element: AstNode,
|
|
3223
|
+
sourceCode: string,
|
|
3224
|
+
namedStoreSignalIds: ReadonlyMap<string, string>,
|
|
3225
|
+
namedStoreTableIds: ReadonlyMap<string, string>
|
|
3226
|
+
): string | null => {
|
|
3227
|
+
if (isStringLiteral(element)) {
|
|
3228
|
+
return getStringValue(element);
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
return (
|
|
3232
|
+
extractStoreSignalIdFromMember(element, namedStoreTableIds) ??
|
|
3233
|
+
resolveNamedOnSignalId(element, sourceCode, namedStoreSignalIds) ??
|
|
3234
|
+
resolveInlineOnSignalId(element)
|
|
3235
|
+
);
|
|
3236
|
+
};
|
|
3237
|
+
|
|
3238
|
+
const addOnTargetSignalIds = (
|
|
3239
|
+
config: AstNode,
|
|
3240
|
+
ids: Set<string>,
|
|
3241
|
+
sourceCode: string,
|
|
3242
|
+
namedStoreSignalIds: ReadonlyMap<string, string>,
|
|
3243
|
+
namedStoreTableIds: ReadonlyMap<string, string>
|
|
3244
|
+
): void => {
|
|
3245
|
+
for (const element of getOnElements(config)) {
|
|
3246
|
+
const signalId = resolveOnElementSignalId(
|
|
3247
|
+
element,
|
|
3248
|
+
sourceCode,
|
|
3249
|
+
namedStoreSignalIds,
|
|
3250
|
+
namedStoreTableIds
|
|
3251
|
+
);
|
|
3252
|
+
if (signalId) {
|
|
3253
|
+
ids.add(signalId);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
};
|
|
3257
|
+
|
|
3258
|
+
export const collectOnTargetSignalIds = (
|
|
3259
|
+
ast: AstNode,
|
|
3260
|
+
sourceCode: string
|
|
3261
|
+
): ReadonlySet<string> => {
|
|
3262
|
+
const ids = new Set<string>();
|
|
3263
|
+
const namedStoreTableIds = collectNamedStoreTableIds(ast);
|
|
3264
|
+
const namedStoreSignalIds = collectNamedStoreSignalIds(
|
|
3265
|
+
ast,
|
|
3266
|
+
namedStoreTableIds
|
|
3267
|
+
);
|
|
3268
|
+
|
|
3269
|
+
for (const definition of findTrailDefinitions(ast)) {
|
|
3270
|
+
if (definition.kind === 'trail') {
|
|
3271
|
+
addOnTargetSignalIds(
|
|
3272
|
+
definition.config,
|
|
3273
|
+
ids,
|
|
3274
|
+
sourceCode,
|
|
3275
|
+
namedStoreSignalIds,
|
|
3276
|
+
namedStoreTableIds
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
return ids;
|
|
3282
|
+
};
|
|
3283
|
+
|
|
3284
|
+
// ---------------------------------------------------------------------------
|
|
3285
|
+
// Misc helpers
|
|
3286
|
+
// ---------------------------------------------------------------------------
|
|
3287
|
+
|
|
3288
|
+
/** Check if a node is a call to `.blaze()` on some object. */
|
|
3289
|
+
export const isBlazeCall = (node: AstNode): boolean => {
|
|
3290
|
+
if (node.type !== 'CallExpression') {
|
|
3291
|
+
return false;
|
|
3292
|
+
}
|
|
3293
|
+
const callee = node['callee'] as AstNode | undefined;
|
|
3294
|
+
if (!callee) {
|
|
3295
|
+
return false;
|
|
3296
|
+
}
|
|
3297
|
+
if (
|
|
3298
|
+
callee.type !== 'StaticMemberExpression' &&
|
|
3299
|
+
callee.type !== 'MemberExpression'
|
|
3300
|
+
) {
|
|
3301
|
+
return false;
|
|
3302
|
+
}
|
|
3303
|
+
const prop = (callee as unknown as { property?: AstNode }).property;
|
|
3304
|
+
return (
|
|
3305
|
+
prop?.type === 'Identifier' &&
|
|
3306
|
+
(prop as unknown as { name: string }).name === 'blaze'
|
|
3307
|
+
);
|
|
3308
|
+
};
|