@ontrails/source 1.0.0-beta.45 → 1.0.0-beta.47
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 +19 -0
- package/README.md +17 -1
- package/package.json +1 -1
- package/src/collection.ts +312 -0
- package/src/index.ts +1 -0
- package/src/nodes.ts +11 -0
- package/src/parse.ts +13 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @ontrails/source
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.47
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`90d394c`](https://github.com/outfitter-dev/trails/commit/90d394c005fdf6b898ba7052d0b56755af0f4954): Derive nested worktree, repository, and submodule collection boundaries in the
|
|
8
|
+
shared Source walker. Regrade and Warden now observe one directly targeted
|
|
9
|
+
working tree per run, and Regrade audit summaries expose boundary skip counts.
|
|
10
|
+
|
|
11
|
+
## 1.0.0-beta.46
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- [`54d259b`](https://github.com/outfitter-dev/trails/commit/54d259be81fb6c41d85be48a6cb2100c746a7126): Expose parser-native comment spans from `parseWithDiagnostics` so source-aware
|
|
16
|
+
tooling can distinguish exact JavaScript and TypeScript comment trivia without
|
|
17
|
+
reimplementing a lexer.
|
|
18
|
+
|
|
19
|
+
Use the shared spans in Warden's public-example rule while keeping leading
|
|
20
|
+
comment ownership fail-closed across JavaScript line terminators.
|
|
21
|
+
|
|
3
22
|
## 1.0.0-beta.45
|
|
4
23
|
|
|
5
24
|
## 1.0.0-beta.44
|
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ Shared source-code machinery for Trails packages and repo tooling.
|
|
|
9
9
|
`@ontrails/source` owns reusable source-code mechanics:
|
|
10
10
|
|
|
11
11
|
- AST node guards and accessors for the OXC node shapes Trails tooling uses.
|
|
12
|
-
- `parse` and `parseWithDiagnostics` wrappers over `oxc-parser
|
|
12
|
+
- `parse` and `parseWithDiagnostics` wrappers over `oxc-parser`, including
|
|
13
|
+
parser-native comment spans for tools that must distinguish source trivia.
|
|
13
14
|
- `walk`, parent-aware walking, and scope-aware walking over `oxc-walker`.
|
|
14
15
|
- Source locations, source edits, literal extraction, and generic Trails syntax recognition.
|
|
15
16
|
- Generic trail/entity discovery helpers such as `findTrailDefinitions`, `findImplementationBodies`, `findEntityDefinitions`, and `isImplementationCall`.
|
|
@@ -44,6 +45,21 @@ const ast = parse(
|
|
|
44
45
|
const trailIds = ast ? findTrailDefinitions(ast).map((trail) => trail.id) : [];
|
|
45
46
|
```
|
|
46
47
|
|
|
48
|
+
Inspect exact comment spans without rebuilding a JavaScript or TypeScript lexer:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { parseWithDiagnostics } from '@ontrails/source';
|
|
52
|
+
|
|
53
|
+
const sourceCode = '/** Describe a trail. */\nexport const show = 1;\n';
|
|
54
|
+
const parsed = parseWithDiagnostics('example.ts', sourceCode);
|
|
55
|
+
const comments = parsed.comments.map((comment) => ({
|
|
56
|
+
...comment,
|
|
57
|
+
source: sourceCode.slice(comment.start, comment.end),
|
|
58
|
+
}));
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Comment spans are returned only when the parser reports no diagnostics. Tools must treat an empty comment inventory on a recovered parse as unknown rather than as proof that the source contains no comments.
|
|
62
|
+
|
|
47
63
|
Walk source with parent context:
|
|
48
64
|
|
|
49
65
|
```ts
|
package/package.json
CHANGED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import type { Dirent } from 'node:fs';
|
|
4
|
+
import { join, posix, relative, resolve, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** Git-derived reasons a nested directory is outside the current observation tree. */
|
|
7
|
+
export type SourceCollectionBoundaryReason =
|
|
8
|
+
| 'nested-repository'
|
|
9
|
+
| 'nested-worktree'
|
|
10
|
+
| 'submodule-boundary';
|
|
11
|
+
|
|
12
|
+
/** Filesystem entry kinds exposed to a source-collection policy. */
|
|
13
|
+
export type SourceCollectionEntryKind = 'directory' | 'file' | 'other';
|
|
14
|
+
|
|
15
|
+
/** A root-relative filesystem entry offered to a source-collection policy. */
|
|
16
|
+
export interface SourceCollectionEntry {
|
|
17
|
+
readonly kind: SourceCollectionEntryKind;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly path: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The action a source-collection policy chooses for one entry. */
|
|
23
|
+
export type SourceCollectionDecision =
|
|
24
|
+
| { readonly action: 'collect' }
|
|
25
|
+
| { readonly action: 'recurse' }
|
|
26
|
+
| { readonly action: 'skip'; readonly reason: string };
|
|
27
|
+
|
|
28
|
+
/** One collected file, with both absolute and root-relative identities. */
|
|
29
|
+
export interface CollectedSourceFile {
|
|
30
|
+
readonly absolutePath: string;
|
|
31
|
+
readonly path: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One visible collection skip. */
|
|
35
|
+
export interface SkippedSourceEntry {
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly reason: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Deterministic result of observing one source tree. */
|
|
41
|
+
export interface SourceTreeCollection {
|
|
42
|
+
readonly files: readonly CollectedSourceFile[];
|
|
43
|
+
readonly root: string;
|
|
44
|
+
readonly skipped: readonly SkippedSourceEntry[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Consumer policy for ordinary entries after Git boundaries are derived. */
|
|
48
|
+
export interface CollectSourceTreeOptions {
|
|
49
|
+
readonly classify?: (
|
|
50
|
+
entry: SourceCollectionEntry
|
|
51
|
+
) => SourceCollectionDecision;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const toPosixRelative = (root: string, absolutePath: string): string => {
|
|
55
|
+
const path = relative(root, absolutePath);
|
|
56
|
+
return sep === posix.sep ? path : path.split(sep).join(posix.sep);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const entryKind = (entry: {
|
|
60
|
+
isDirectory(): boolean;
|
|
61
|
+
isFile(): boolean;
|
|
62
|
+
}): SourceCollectionEntryKind => {
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
return 'directory';
|
|
65
|
+
}
|
|
66
|
+
return entry.isFile() ? 'file' : 'other';
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const defaultDecision = (
|
|
70
|
+
entry: SourceCollectionEntry
|
|
71
|
+
): SourceCollectionDecision => {
|
|
72
|
+
if (entry.kind === 'directory') {
|
|
73
|
+
return { action: 'recurse' };
|
|
74
|
+
}
|
|
75
|
+
return entry.kind === 'file'
|
|
76
|
+
? { action: 'collect' }
|
|
77
|
+
: { action: 'skip', reason: 'unsupported-entry' };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
interface SubmodulePathSnapshot {
|
|
81
|
+
readonly paths: ReadonlySet<string>;
|
|
82
|
+
readonly readable: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const readSubmodulePaths = (root: string): SubmodulePathSnapshot => {
|
|
86
|
+
const gitmodulesPath = join(root, '.gitmodules');
|
|
87
|
+
let metadataStats: ReturnType<typeof lstatSync>;
|
|
88
|
+
try {
|
|
89
|
+
metadataStats = lstatSync(gitmodulesPath);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return (error as NodeJS.ErrnoException).code === 'ENOENT'
|
|
92
|
+
? { paths: new Set(), readable: true }
|
|
93
|
+
: { paths: new Set(), readable: false };
|
|
94
|
+
}
|
|
95
|
+
if (!metadataStats.isFile()) {
|
|
96
|
+
return { paths: new Set(), readable: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let source: string;
|
|
100
|
+
try {
|
|
101
|
+
source = readFileSync(gitmodulesPath, 'utf8');
|
|
102
|
+
} catch {
|
|
103
|
+
return { paths: new Set(), readable: false };
|
|
104
|
+
}
|
|
105
|
+
const parsed = spawnSync(
|
|
106
|
+
'git',
|
|
107
|
+
['config', '--file', '-', '--null', '--get-regexp', '^submodule\\.'],
|
|
108
|
+
{ encoding: 'utf8', input: source }
|
|
109
|
+
);
|
|
110
|
+
if (parsed.status === 1) {
|
|
111
|
+
const hasOnlyComments = source
|
|
112
|
+
.split(/\r?\n/)
|
|
113
|
+
.every((line) => !line.trim() || /^[#;]/.test(line.trim()));
|
|
114
|
+
return {
|
|
115
|
+
paths: new Set(),
|
|
116
|
+
readable: hasOnlyComments,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (parsed.status !== 0) {
|
|
120
|
+
return { paths: new Set(), readable: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const paths = new Set<string>();
|
|
124
|
+
const owners = new Set<string>();
|
|
125
|
+
const ownersWithPaths = new Set<string>();
|
|
126
|
+
const authoredSubmodules = new Set(
|
|
127
|
+
[
|
|
128
|
+
...source.matchAll(
|
|
129
|
+
/^\s*\[\s*submodule(?:\s+"((?:[^"\\]|\\.)*)"|\.([^\]]+?))\s*\]/gim
|
|
130
|
+
),
|
|
131
|
+
].map((match) => match[1] ?? match[2]?.trim())
|
|
132
|
+
);
|
|
133
|
+
for (const record of parsed.stdout.split('\0').filter(Boolean)) {
|
|
134
|
+
const separator = record.indexOf('\n');
|
|
135
|
+
if (separator === -1) {
|
|
136
|
+
return { paths: new Set(), readable: false };
|
|
137
|
+
}
|
|
138
|
+
const key = record.slice(0, separator);
|
|
139
|
+
const fieldSeparator = key.lastIndexOf('.');
|
|
140
|
+
if (fieldSeparator <= 'submodule.'.length) {
|
|
141
|
+
return { paths: new Set(), readable: false };
|
|
142
|
+
}
|
|
143
|
+
const owner = key.slice('submodule.'.length, fieldSeparator);
|
|
144
|
+
owners.add(owner);
|
|
145
|
+
if (key.slice(fieldSeparator + 1).toLowerCase() !== 'path') {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const value = record.slice(separator + 1);
|
|
149
|
+
if (!value) {
|
|
150
|
+
return { paths: new Set(), readable: false };
|
|
151
|
+
}
|
|
152
|
+
ownersWithPaths.add(owner);
|
|
153
|
+
paths.add(posix.normalize(value.replaceAll('\\', '/')).replace(/\/+$/, ''));
|
|
154
|
+
}
|
|
155
|
+
return owners.size === ownersWithPaths.size &&
|
|
156
|
+
authoredSubmodules.size === ownersWithPaths.size
|
|
157
|
+
? { paths, readable: true }
|
|
158
|
+
: { paths: new Set(), readable: false };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const boundaryReason = (
|
|
162
|
+
absoluteDirectory: string,
|
|
163
|
+
path: string,
|
|
164
|
+
submodules: SubmodulePathSnapshot
|
|
165
|
+
): SourceCollectionBoundaryReason | 'unreadable-git-boundary' | undefined => {
|
|
166
|
+
if (!submodules.readable) {
|
|
167
|
+
return 'unreadable-git-boundary';
|
|
168
|
+
}
|
|
169
|
+
if (submodules.paths.has(path)) {
|
|
170
|
+
return 'submodule-boundary';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const marker = join(absoluteDirectory, '.git');
|
|
174
|
+
let markerStats: ReturnType<typeof lstatSync>;
|
|
175
|
+
try {
|
|
176
|
+
markerStats = lstatSync(marker);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return (error as NodeJS.ErrnoException).code === 'ENOENT'
|
|
179
|
+
? undefined
|
|
180
|
+
: 'unreadable-git-boundary';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (markerStats.isDirectory()) {
|
|
185
|
+
return statSync(join(marker, 'HEAD')).isFile() &&
|
|
186
|
+
statSync(join(marker, 'objects')).isDirectory()
|
|
187
|
+
? 'nested-repository'
|
|
188
|
+
: 'unreadable-git-boundary';
|
|
189
|
+
}
|
|
190
|
+
if (!markerStats.isFile()) {
|
|
191
|
+
return 'unreadable-git-boundary';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const pointer = readFileSync(marker, 'utf8').match(/^gitdir:\s*(.+?)\s*$/);
|
|
195
|
+
const gitDirectory = pointer?.[1];
|
|
196
|
+
if (!gitDirectory) {
|
|
197
|
+
return 'unreadable-git-boundary';
|
|
198
|
+
}
|
|
199
|
+
const resolvedGitDirectory = resolve(absoluteDirectory, gitDirectory);
|
|
200
|
+
return statSync(resolvedGitDirectory).isDirectory() &&
|
|
201
|
+
statSync(join(resolvedGitDirectory, 'HEAD')).isFile()
|
|
202
|
+
? 'nested-worktree'
|
|
203
|
+
: 'unreadable-git-boundary';
|
|
204
|
+
} catch {
|
|
205
|
+
return 'unreadable-git-boundary';
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const comparePath = (
|
|
210
|
+
left: { readonly path: string },
|
|
211
|
+
right: {
|
|
212
|
+
readonly path: string;
|
|
213
|
+
}
|
|
214
|
+
): number => {
|
|
215
|
+
if (left.path < right.path) {
|
|
216
|
+
return -1;
|
|
217
|
+
}
|
|
218
|
+
return left.path > right.path ? 1 : 0;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Collect files from exactly one working tree, pruning nested Git observations.
|
|
223
|
+
*
|
|
224
|
+
* Git boundaries take precedence over consumer policy so a tool cannot scan a
|
|
225
|
+
* nested checkout and hide that fact behind an authored exclude. The supplied
|
|
226
|
+
* root remains first-class: only directories encountered beneath it are
|
|
227
|
+
* classified as boundaries.
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```ts
|
|
231
|
+
* const result = collectSourceTree(process.cwd(), {
|
|
232
|
+
* classify: (entry) =>
|
|
233
|
+
* entry.kind === 'file' && entry.path.endsWith('.ts')
|
|
234
|
+
* ? { action: 'collect' }
|
|
235
|
+
* : entry.kind === 'directory'
|
|
236
|
+
* ? { action: 'recurse' }
|
|
237
|
+
* : { action: 'skip', reason: 'unsupported-extension' },
|
|
238
|
+
* });
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
export const collectSourceTree = (
|
|
242
|
+
root: string,
|
|
243
|
+
options: CollectSourceTreeOptions = {}
|
|
244
|
+
): SourceTreeCollection | null => {
|
|
245
|
+
const absoluteRoot = resolve(root);
|
|
246
|
+
let rootEntries: readonly Dirent<string>[];
|
|
247
|
+
try {
|
|
248
|
+
rootEntries = readdirSync(absoluteRoot, { withFileTypes: true });
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const classify = options.classify ?? defaultDecision;
|
|
254
|
+
const submodules = readSubmodulePaths(absoluteRoot);
|
|
255
|
+
const files: CollectedSourceFile[] = [];
|
|
256
|
+
const skipped: SkippedSourceEntry[] = submodules.readable
|
|
257
|
+
? []
|
|
258
|
+
: [{ path: '.gitmodules', reason: 'unreadable-git-metadata' }];
|
|
259
|
+
const queue: {
|
|
260
|
+
readonly absolutePath: string;
|
|
261
|
+
readonly entries?: readonly Dirent<string>[];
|
|
262
|
+
}[] = [{ absolutePath: absoluteRoot, entries: rootEntries }];
|
|
263
|
+
|
|
264
|
+
while (queue.length > 0) {
|
|
265
|
+
const current = queue.shift() as (typeof queue)[number];
|
|
266
|
+
let { entries } = current;
|
|
267
|
+
if (!entries) {
|
|
268
|
+
try {
|
|
269
|
+
entries = readdirSync(current.absolutePath, { withFileTypes: true });
|
|
270
|
+
} catch {
|
|
271
|
+
skipped.push({
|
|
272
|
+
path: toPosixRelative(absoluteRoot, current.absolutePath),
|
|
273
|
+
reason: 'unreadable-directory',
|
|
274
|
+
});
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
const absolutePath = join(current.absolutePath, entry.name);
|
|
281
|
+
const path = toPosixRelative(absoluteRoot, absolutePath);
|
|
282
|
+
const kind = entryKind(entry);
|
|
283
|
+
if (path === '.gitmodules' && !submodules.readable) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (kind === 'directory') {
|
|
287
|
+
const reason = boundaryReason(absolutePath, path, submodules);
|
|
288
|
+
if (reason) {
|
|
289
|
+
skipped.push({ path, reason });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (entry.name === '.git') {
|
|
294
|
+
skipped.push({ path, reason: 'ignored-directory' });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const decision = classify({ kind, name: entry.name, path });
|
|
299
|
+
if (decision.action === 'collect') {
|
|
300
|
+
files.push({ absolutePath, path });
|
|
301
|
+
} else if (decision.action === 'recurse') {
|
|
302
|
+
queue.push({ absolutePath });
|
|
303
|
+
} else {
|
|
304
|
+
skipped.push({ path, reason: decision.reason });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
files.sort(comparePath);
|
|
310
|
+
skipped.sort(comparePath);
|
|
311
|
+
return { files, root: absoluteRoot, skipped };
|
|
312
|
+
};
|
package/src/index.ts
CHANGED
package/src/nodes.ts
CHANGED
|
@@ -245,8 +245,19 @@ export interface AstParseDiagnostic {
|
|
|
245
245
|
readonly severity: string;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/** Parser-native source comment span. Offsets include the comment delimiters. */
|
|
249
|
+
export interface SourceComment {
|
|
250
|
+
readonly end: number;
|
|
251
|
+
readonly start: number;
|
|
252
|
+
readonly type: 'Block' | 'Line';
|
|
253
|
+
/** Comment text without the line or block delimiters. */
|
|
254
|
+
readonly value: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
248
257
|
export interface AstParseResult {
|
|
249
258
|
readonly ast: AstNode | null;
|
|
259
|
+
/** Exact parser-native comments for a diagnostic-free parse; empty on errors. */
|
|
260
|
+
readonly comments: readonly SourceComment[];
|
|
250
261
|
readonly diagnostics: readonly AstParseDiagnostic[];
|
|
251
262
|
}
|
|
252
263
|
|
package/src/parse.ts
CHANGED
|
@@ -25,22 +25,25 @@ export const parseWithDiagnostics = (
|
|
|
25
25
|
): AstParseResult => {
|
|
26
26
|
try {
|
|
27
27
|
const result = parseSync(filePath, sourceCode, { sourceType: 'module' });
|
|
28
|
+
const diagnostics = result.errors.map((error) => ({
|
|
29
|
+
helpMessage: error.helpMessage,
|
|
30
|
+
labels: error.labels.map((label) => ({
|
|
31
|
+
end: label.end,
|
|
32
|
+
message: label.message,
|
|
33
|
+
start: label.start,
|
|
34
|
+
})),
|
|
35
|
+
message: error.message,
|
|
36
|
+
severity: error.severity,
|
|
37
|
+
}));
|
|
28
38
|
return {
|
|
29
39
|
ast: result.program as unknown as AstNode,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
labels: error.labels.map((label) => ({
|
|
33
|
-
end: label.end,
|
|
34
|
-
message: label.message,
|
|
35
|
-
start: label.start,
|
|
36
|
-
})),
|
|
37
|
-
message: error.message,
|
|
38
|
-
severity: error.severity,
|
|
39
|
-
})),
|
|
40
|
+
comments: diagnostics.length === 0 ? result.comments : [],
|
|
41
|
+
diagnostics,
|
|
40
42
|
};
|
|
41
43
|
} catch (error) {
|
|
42
44
|
return {
|
|
43
45
|
ast: null,
|
|
46
|
+
comments: [],
|
|
44
47
|
diagnostics: [
|
|
45
48
|
{
|
|
46
49
|
helpMessage: null,
|