@ontrails/source 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +179 -0
- package/README.md +96 -0
- package/package.json +34 -0
- package/src/collection.ts +329 -0
- package/src/edits.ts +57 -0
- package/src/index.ts +25 -0
- package/src/literals.ts +234 -0
- package/src/locations.ts +35 -0
- package/src/nodes.ts +661 -0
- package/src/parse.ts +58 -0
- package/src/scopes.ts +517 -0
- package/src/trails.ts +826 -0
- package/src/walk.ts +87 -0
package/src/edits.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Shared source-edit helpers. */
|
|
2
|
+
|
|
3
|
+
import type { SourceEdit } from './nodes.js';
|
|
4
|
+
|
|
5
|
+
export const createSourceEdit = (
|
|
6
|
+
start: number,
|
|
7
|
+
end: number,
|
|
8
|
+
replacement: string
|
|
9
|
+
): SourceEdit => ({ end, replacement, start });
|
|
10
|
+
|
|
11
|
+
export const validateSourceEdits = (
|
|
12
|
+
edits: readonly SourceEdit[],
|
|
13
|
+
sourceLength?: number
|
|
14
|
+
): readonly SourceEdit[] => {
|
|
15
|
+
const ordered = [...edits].toSorted(
|
|
16
|
+
(left, right) => left.start - right.start
|
|
17
|
+
);
|
|
18
|
+
for (let i = 0; i < ordered.length; i += 1) {
|
|
19
|
+
const edit = ordered[i];
|
|
20
|
+
if (!edit) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (
|
|
24
|
+
!Number.isSafeInteger(edit.start) ||
|
|
25
|
+
!Number.isSafeInteger(edit.end) ||
|
|
26
|
+
edit.start < 0 ||
|
|
27
|
+
edit.end < edit.start ||
|
|
28
|
+
(sourceLength !== undefined && edit.end > sourceLength)
|
|
29
|
+
) {
|
|
30
|
+
throw new Error(`Invalid source edit range ${edit.start}-${edit.end}.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const previous = ordered[i - 1];
|
|
34
|
+
if (previous && edit.start < previous.end) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Overlapping source edits ${previous.start}-${previous.end} and ${edit.start}-${edit.end}.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return ordered;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const applySourceEdits = (
|
|
45
|
+
sourceCode: string,
|
|
46
|
+
edits: readonly SourceEdit[]
|
|
47
|
+
): string => {
|
|
48
|
+
validateSourceEdits(edits, sourceCode.length);
|
|
49
|
+
|
|
50
|
+
return [...edits]
|
|
51
|
+
.toSorted((left, right) => right.start - left.start)
|
|
52
|
+
.reduce(
|
|
53
|
+
(output, edit) =>
|
|
54
|
+
output.slice(0, edit.start) + edit.replacement + output.slice(edit.end),
|
|
55
|
+
sourceCode
|
|
56
|
+
);
|
|
57
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export * from './nodes.js';
|
|
2
|
+
export * from './parse.js';
|
|
3
|
+
export * from './walk.js';
|
|
4
|
+
export * from './scopes.js';
|
|
5
|
+
export * from './locations.js';
|
|
6
|
+
export * from './edits.js';
|
|
7
|
+
export * from './literals.js';
|
|
8
|
+
export * from './collection.js';
|
|
9
|
+
export type {
|
|
10
|
+
EntityDefinition,
|
|
11
|
+
FindEntityDefinitionsOptions,
|
|
12
|
+
FrameworkNamespaceContext,
|
|
13
|
+
TrailDefinition,
|
|
14
|
+
} from './trails.js';
|
|
15
|
+
export {
|
|
16
|
+
buildFrameworkNamespaceContext,
|
|
17
|
+
extractEntityDefinition,
|
|
18
|
+
extractTrailDefinition,
|
|
19
|
+
findEntityDefinitions,
|
|
20
|
+
findImplementationBodies,
|
|
21
|
+
findTrailDefinitions,
|
|
22
|
+
getImportSourceValue,
|
|
23
|
+
isImplementationCall,
|
|
24
|
+
isFrameworkNamespaceSource,
|
|
25
|
+
} from './trails.js';
|
package/src/literals.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** Shared literal, string, and static property-key helpers. */
|
|
2
|
+
|
|
3
|
+
import { isAstNode } from './nodes.js';
|
|
4
|
+
import type { AstNode, StringLiteralNode } from './nodes.js';
|
|
5
|
+
import { walk } from './walk.js';
|
|
6
|
+
|
|
7
|
+
export const identifierName = (node: AstNode | undefined): string | null => {
|
|
8
|
+
if (node?.type !== 'Identifier') {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
return (node as unknown as { name?: string }).name ?? null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Check if a node is a string literal. */
|
|
15
|
+
export const isStringLiteral = (
|
|
16
|
+
node: AstNode | undefined
|
|
17
|
+
): node is StringLiteralNode => {
|
|
18
|
+
if (!node) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (node.type === 'StringLiteral') {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (node.type === 'Literal') {
|
|
25
|
+
return typeof (node as unknown as { value?: unknown }).value === 'string';
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Extract the string value from a string literal node. */
|
|
31
|
+
export const getStringValue = (node: AstNode): string | null => {
|
|
32
|
+
const val = (node as unknown as { value?: unknown }).value;
|
|
33
|
+
return typeof val === 'string' ? val : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Best-effort resolution of `const NAME = 'value'` declarations via regex.
|
|
38
|
+
*
|
|
39
|
+
* Returns the string value if a simple `const <name> = '...'` or `"..."` is
|
|
40
|
+
* found in the source. Returns null for anything more complex. Shared between
|
|
41
|
+
* warden rules that need to resolve identifier references to signal / trail
|
|
42
|
+
* IDs at lint time.
|
|
43
|
+
*/
|
|
44
|
+
export const deriveConstString = (
|
|
45
|
+
name: string,
|
|
46
|
+
sourceCode: string
|
|
47
|
+
): string | null => {
|
|
48
|
+
const pattern = new RegExp(
|
|
49
|
+
`const\\s+${name}\\s*=\\s*(?:'([^']*)'|"([^"]*)")`
|
|
50
|
+
);
|
|
51
|
+
const match = pattern.exec(sourceCode);
|
|
52
|
+
if (!match) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return match[1] ?? match[2] ?? null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Extract a string literal value, or null when the node is not a string. */
|
|
59
|
+
export const extractStringLiteral = (
|
|
60
|
+
node: AstNode | undefined
|
|
61
|
+
): string | null =>
|
|
62
|
+
node && isStringLiteral(node) ? getStringValue(node) : null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract the cooked value from a `TemplateLiteral` with no interpolations
|
|
66
|
+
* (e.g. `` `entity.fallback` ``). Template literals with `${...}` expressions
|
|
67
|
+
* cannot be resolved at lint time and return null.
|
|
68
|
+
*
|
|
69
|
+
* Shared helper used by rules that accept both string literals and simple
|
|
70
|
+
* backtick-literal IDs (e.g. `valid-describe-refs`).
|
|
71
|
+
*/
|
|
72
|
+
const getSingleQuasi = (node: AstNode): AstNode | null => {
|
|
73
|
+
const expressions =
|
|
74
|
+
(node['expressions'] as readonly AstNode[] | undefined) ?? [];
|
|
75
|
+
if (expressions.length > 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const quasis = (node['quasis'] as readonly AstNode[] | undefined) ?? [];
|
|
79
|
+
return quasis.length === 1 ? (quasis[0] ?? null) : null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const extractPlainTemplateLiteral = (
|
|
83
|
+
node: AstNode | undefined
|
|
84
|
+
): string | null => {
|
|
85
|
+
if (!node || node.type !== 'TemplateLiteral') {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const quasi = getSingleQuasi(node);
|
|
89
|
+
if (!quasi) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const cooked = (quasi as unknown as { value?: { cooked?: unknown } }).value
|
|
93
|
+
?.cooked;
|
|
94
|
+
return typeof cooked === 'string' ? cooked : null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract a string value from either a string literal or a plain template
|
|
99
|
+
* literal (no `${...}` expressions). Returns null for anything else.
|
|
100
|
+
*/
|
|
101
|
+
export const extractStringOrTemplateLiteral = (
|
|
102
|
+
node: AstNode | undefined
|
|
103
|
+
): string | null =>
|
|
104
|
+
extractStringLiteral(node) ?? extractPlainTemplateLiteral(node);
|
|
105
|
+
|
|
106
|
+
export interface StringLiteralMatch {
|
|
107
|
+
readonly end: number;
|
|
108
|
+
readonly node: AstNode;
|
|
109
|
+
readonly start: number;
|
|
110
|
+
readonly value: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const findStringLiterals = (
|
|
114
|
+
ast: AstNode,
|
|
115
|
+
predicate?: (value: string, node: AstNode) => boolean
|
|
116
|
+
): StringLiteralMatch[] => {
|
|
117
|
+
const matches: StringLiteralMatch[] = [];
|
|
118
|
+
|
|
119
|
+
walk(ast, (node) => {
|
|
120
|
+
if (!isStringLiteral(node)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const value = getStringValue(node);
|
|
125
|
+
if (value === null) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (predicate && !predicate(value, node)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
matches.push({
|
|
134
|
+
end: node.end,
|
|
135
|
+
node,
|
|
136
|
+
start: node.start,
|
|
137
|
+
value,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return matches;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Extract the first string argument from a CallExpression. */
|
|
145
|
+
export const extractFirstStringArg = (node: AstNode): string | null => {
|
|
146
|
+
if (node.type !== 'CallExpression') {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
151
|
+
const [firstArg] = args ?? [];
|
|
152
|
+
return extractStringLiteral(firstArg);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const extractBindingName = (
|
|
156
|
+
node: AstNode | undefined
|
|
157
|
+
): string | null => {
|
|
158
|
+
if (!node) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (node.type === 'Identifier') {
|
|
162
|
+
return identifierName(node);
|
|
163
|
+
}
|
|
164
|
+
if (node.type === 'AssignmentPattern') {
|
|
165
|
+
return identifierName((node as unknown as { left?: AstNode }).left);
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const staticPropertyKeyName = (key: AstNode): string | null => {
|
|
171
|
+
if (key.type === 'Identifier') {
|
|
172
|
+
return (key as unknown as { name?: string }).name ?? null;
|
|
173
|
+
}
|
|
174
|
+
if (isStringLiteral(key)) {
|
|
175
|
+
return getStringValue(key);
|
|
176
|
+
}
|
|
177
|
+
const { value } = key as unknown as { value?: unknown };
|
|
178
|
+
return (key.type === 'Literal' || key.type === 'NumericLiteral') &&
|
|
179
|
+
typeof value === 'number' &&
|
|
180
|
+
Number.isFinite(value)
|
|
181
|
+
? String(value)
|
|
182
|
+
: null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const propertyKeyName = (prop: AstNode): string | null => {
|
|
186
|
+
if (prop.type !== 'Property') {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const { computed } = prop as unknown as { computed?: boolean };
|
|
190
|
+
if (computed) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
const key = prop.key as AstNode | undefined;
|
|
194
|
+
return key ? staticPropertyKeyName(key) : null;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Find a Property node by key name inside an ObjectExpression config. */
|
|
198
|
+
export const findConfigProperty = (
|
|
199
|
+
config: AstNode,
|
|
200
|
+
propertyName: string
|
|
201
|
+
): AstNode | null => {
|
|
202
|
+
if (config.type !== 'ObjectExpression') {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const properties = config['properties'] as readonly AstNode[] | undefined;
|
|
206
|
+
if (!properties) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
for (const prop of properties) {
|
|
210
|
+
if (propertyKeyName(prop) === propertyName) {
|
|
211
|
+
return prop;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read a property key or member access identifier.
|
|
219
|
+
*
|
|
220
|
+
* Returns the identifier name for `Identifier` keys, or the underlying
|
|
221
|
+
* string literal value for computed access via `['name']` / `"name"`.
|
|
222
|
+
*/
|
|
223
|
+
export const getPropertyName = (node: unknown): string | null => {
|
|
224
|
+
if (typeof node !== 'object' || node === null) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const { name } = node as { readonly name?: unknown };
|
|
229
|
+
if (typeof name === 'string') {
|
|
230
|
+
return name;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return isAstNode(node) ? extractStringLiteral(node) : null;
|
|
234
|
+
};
|
package/src/locations.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Shared source location helpers. */
|
|
2
|
+
|
|
3
|
+
import type { SourceLocation } from './nodes.js';
|
|
4
|
+
|
|
5
|
+
/** Find the byte offset's line number (1-based) in source code. */
|
|
6
|
+
export const offsetToLine = (sourceCode: string, offset: number): number => {
|
|
7
|
+
let line = 1;
|
|
8
|
+
for (let i = 0; i < offset && i < sourceCode.length; i += 1) {
|
|
9
|
+
if (sourceCode[i] === '\n') {
|
|
10
|
+
line += 1;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return line;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Find the byte offset's line and column (1-based) in source code. */
|
|
17
|
+
export const offsetToLineColumn = (
|
|
18
|
+
sourceCode: string,
|
|
19
|
+
offset: number
|
|
20
|
+
): SourceLocation => {
|
|
21
|
+
let line = 1;
|
|
22
|
+
let column = 1;
|
|
23
|
+
const limit = Math.min(Math.max(offset, 0), sourceCode.length);
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < limit; i += 1) {
|
|
26
|
+
if (sourceCode[i] === '\n') {
|
|
27
|
+
line += 1;
|
|
28
|
+
column = 1;
|
|
29
|
+
} else {
|
|
30
|
+
column += 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { column, line };
|
|
35
|
+
};
|