@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/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';
@@ -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
+ };
@@ -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
+ };