@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# @ontrails/source
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`b3ddf91`](https://github.com/outfitter-dev/trails/commit/b3ddf918ada8211f44983512e2bbdfbc5b66d722): Prepare the first normal Trails release at `0.2.0` on `latest`, replacing the unpublished 1.0.0 source release. The target advances the original `0.1.0` source minor, which was never published under the current package names. Public packages remain in lockstep. Update consumer installation guidance and provide a temporary manifest bridge for old 1.0 beta sources. Published beta versions remain unchanged; minor 0.x releases may carry documented breaking changes.
|
|
8
|
+
|
|
9
|
+
The `1.0.0` section retained below records an unpublished preparation, not an npm release. Its accumulated changes are included in `0.2.0`; the section remains as source history alongside the published beta entries.
|
|
10
|
+
|
|
11
|
+
## 1.0.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- [`90d394c`](https://github.com/outfitter-dev/trails/commit/90d394c005fdf6b898ba7052d0b56755af0f4954): Derive nested worktree, repository, and submodule collection boundaries in the
|
|
16
|
+
shared Source walker. Regrade and Warden now observe one directly targeted
|
|
17
|
+
working tree per run, and Regrade audit summaries expose boundary skip counts.
|
|
18
|
+
- [`54d259b`](https://github.com/outfitter-dev/trails/commit/54d259be81fb6c41d85be48a6cb2100c746a7126): Expose parser-native comment spans from `parseWithDiagnostics` so source-aware
|
|
19
|
+
tooling can distinguish exact JavaScript and TypeScript comment trivia without
|
|
20
|
+
reimplementing a lexer.
|
|
21
|
+
|
|
22
|
+
Use the shared spans in Warden's public-example rule while keeping leading
|
|
23
|
+
comment ownership fail-closed across JavaScript line terminators.
|
|
24
|
+
|
|
25
|
+
- [`88a6a62`](https://github.com/outfitter-dev/trails/commit/88a6a62a9e9e230ca6d368fa78dc3ece6c816204): Complete the v1 classification-first cutover from projection/project vocabulary
|
|
26
|
+
to derive/derived for contract-owned fact production and render/rendered for
|
|
27
|
+
surface presentation. Public type, helper, rule, relation, and report names move
|
|
28
|
+
without compatibility aliases; ordinary repository/project nouns remain
|
|
29
|
+
explicit preserves or structured review inventory.
|
|
30
|
+
- [`35cbe28`](https://github.com/outfitter-dev/trails/commit/35cbe289db46539b3689dbf6cf8ab0e5d9a1b09c): Found `@ontrails/source` as the shared source-code AST kernel for parsing,
|
|
31
|
+
walking, locations, edits, literals, and generic Trails syntax recognition.
|
|
32
|
+
Warden, Regrade, Wayfinder, and the Trails operator now import those shared
|
|
33
|
+
mechanics from `@ontrails/source`; the legacy Warden AST route is removed by the
|
|
34
|
+
stacked hard cutover.
|
|
35
|
+
|
|
36
|
+
### Patch Changes
|
|
37
|
+
|
|
38
|
+
- [`1679687`](https://github.com/outfitter-dev/trails/commit/1679687219431c38cdc7f46de8de60ccac49fd3c): Add Config-owned static `workspace.apps` identity with bounded, non-executing TypeScript extraction, shared data-format validation, convention-derived app entries, and normalized project paths. Type-only wrappers remain transparent, unrelated deployment expressions stay outside identity proof, duplicate identity keys fail before parser collapse, separator aliases cannot create competing app-root owners, and Source preserves declared submodule boundaries when their checkouts are absent. YAML workspace identity must stay literal, JSON-compatible data: alias references and merge keys inside the workspace subtree now fail closed before identity resolution instead of collapsing silently at parse time, while an anchor definition that nothing references stays inert and cannot alter the resolved identity. Invalid discovery start directories also fail closed: a start path that is missing or is not a directory now raises a typed `ValidationError` naming it instead of silently walking up to an ancestor project's identity.
|
|
39
|
+
- [`f556559`](https://github.com/outfitter-dev/trails/commit/f556559b9e8645cf0d2b1763e199ce6efec8ea29): Carries the curated typed AST node guards into `@ontrails/source` so source
|
|
40
|
+
consumers can narrow common OXC node shapes without assertion casts.
|
|
41
|
+
- [`b1fbe57`](https://github.com/outfitter-dev/trails/commit/b1fbe574e6f44d1fecb5e3a000270955c0a77b7b): Publish Bun-validated package tarballs through an npm trusted-publishing adapter
|
|
42
|
+
binding, add exact repository metadata for each public workspace package, and
|
|
43
|
+
correct the native Bun release descriptor to its pack-only runtime boundary.
|
|
44
|
+
- [`3531b58`](https://github.com/outfitter-dev/trails/commit/3531b58ba5320753d6d2594257ef71bc950d28a1): Add the advisory captured-kernel Warden rule for ownership review when a public
|
|
45
|
+
subpath re-exports package internals and multiple production workspaces consume
|
|
46
|
+
that subpath, including import-then-export barrels that preserve the internal
|
|
47
|
+
binding through a local alias or default export.
|
|
48
|
+
|
|
49
|
+
Expose typed import-kind inspection from `@ontrails/source` so project rules
|
|
50
|
+
can keep erased type bindings separate from runtime exports.
|
|
51
|
+
|
|
52
|
+
- [`10f2492`](https://github.com/outfitter-dev/trails/commit/10f24928d3bc9d995abf7aa261ecf515c295855d): Own the `wayfind.outline` implementation in the Trails operator app while preserving the existing `trails wayfind file <file> --outline` CLI and MCP composition behavior, and document `@ontrails/source` as the operator's live-source analysis kernel.
|
|
53
|
+
- [`35e5fed`](https://github.com/outfitter-dev/trails/commit/35e5fedd228e498783f479f0dd502e2f3ec772b8): Fold the Wayfinder graph-read catalog into `@ontrails/topography`. Wayfind
|
|
54
|
+
remains the product, trail-id, CLI, and MCP brand, but there is no longer an
|
|
55
|
+
`@ontrails/wayfinder` package to install or import. Programmatic consumers
|
|
56
|
+
should move imports such as `wayfinderTopo`, `wayfindOverviewTrail`,
|
|
57
|
+
`loadWayfinderArtifacts`, and the Wayfinder filter/provenance types to
|
|
58
|
+
`@ontrails/topography`.
|
|
59
|
+
|
|
60
|
+
Expose that package move as a governed Regrade transition so exact
|
|
61
|
+
`@ontrails/wayfinder` imports can move safely while product vocabulary and near
|
|
62
|
+
routes remain unchanged for review. Regrade routes package manifests through
|
|
63
|
+
structured review instead of rewriting dependency keys as plain text.
|
|
64
|
+
|
|
65
|
+
The Trails operator now reads all `wayfind.*` query trails and artifact helpers
|
|
66
|
+
from `@ontrails/topography` while preserving the existing CLI/MCP schemas,
|
|
67
|
+
route IDs, output shapes, and internal trail visibility.
|
|
68
|
+
|
|
69
|
+
- [`3a65ae3`](https://github.com/outfitter-dev/trails/commit/3a65ae363e05b7589f4a9876da4346886353b48c): Rename the durable graph substrate package from `@ontrails/topographer` to
|
|
70
|
+
`@ontrails/topography` after folding Wayfind graph queries into that owner.
|
|
71
|
+
|
|
72
|
+
Update imports to `@ontrails/topography` or
|
|
73
|
+
`@ontrails/topography/backend-support`. The pre-1.0 cutover does not ship a
|
|
74
|
+
compatibility package. TopoGraph, lock, topo-store, semantic diff, and Wayfind
|
|
75
|
+
APIs keep their existing contracts, and the `trails wayfind` CLI and MCP names
|
|
76
|
+
remain unchanged.
|
|
77
|
+
|
|
78
|
+
The governed package-route transition moves legacy `@ontrails/wayfinder`
|
|
79
|
+
imports directly to `@ontrails/topography`; it does not emit the retired
|
|
80
|
+
intermediate `@ontrails/topographer` route.
|
|
81
|
+
|
|
82
|
+
## 1.0.0-beta.50
|
|
83
|
+
|
|
84
|
+
## 1.0.0-beta.49
|
|
85
|
+
|
|
86
|
+
## 1.0.0-beta.48
|
|
87
|
+
|
|
88
|
+
## 1.0.0-beta.47
|
|
89
|
+
|
|
90
|
+
### Minor Changes
|
|
91
|
+
|
|
92
|
+
- [`90d394c`](https://github.com/outfitter-dev/trails/commit/90d394c005fdf6b898ba7052d0b56755af0f4954): Derive nested worktree, repository, and submodule collection boundaries in the
|
|
93
|
+
shared Source walker. Regrade and Warden now observe one directly targeted
|
|
94
|
+
working tree per run, and Regrade audit summaries expose boundary skip counts.
|
|
95
|
+
|
|
96
|
+
## 1.0.0-beta.46
|
|
97
|
+
|
|
98
|
+
### Minor Changes
|
|
99
|
+
|
|
100
|
+
- [`54d259b`](https://github.com/outfitter-dev/trails/commit/54d259be81fb6c41d85be48a6cb2100c746a7126): Expose parser-native comment spans from `parseWithDiagnostics` so source-aware
|
|
101
|
+
tooling can distinguish exact JavaScript and TypeScript comment trivia without
|
|
102
|
+
reimplementing a lexer.
|
|
103
|
+
|
|
104
|
+
Use the shared spans in Warden's public-example rule while keeping leading
|
|
105
|
+
comment ownership fail-closed across JavaScript line terminators.
|
|
106
|
+
|
|
107
|
+
## 1.0.0-beta.45
|
|
108
|
+
|
|
109
|
+
## 1.0.0-beta.44
|
|
110
|
+
|
|
111
|
+
### Patch Changes
|
|
112
|
+
|
|
113
|
+
- [`b1fbe57`](https://github.com/outfitter-dev/trails/commit/b1fbe574e6f44d1fecb5e3a000270955c0a77b7b): Publish Bun-validated package tarballs through an npm trusted-publishing adapter
|
|
114
|
+
binding, add exact repository metadata for each public workspace package, and
|
|
115
|
+
correct the native Bun release descriptor to its pack-only runtime boundary.
|
|
116
|
+
|
|
117
|
+
## 1.0.0-beta.43
|
|
118
|
+
|
|
119
|
+
### Minor Changes
|
|
120
|
+
|
|
121
|
+
- [`88a6a62`](https://github.com/outfitter-dev/trails/commit/88a6a62a9e9e230ca6d368fa78dc3ece6c816204): Complete the v1 classification-first cutover from projection/project vocabulary
|
|
122
|
+
to derive/derived for contract-owned fact production and render/rendered for
|
|
123
|
+
surface presentation. Public type, helper, rule, relation, and report names move
|
|
124
|
+
without compatibility aliases; ordinary repository/project nouns remain
|
|
125
|
+
explicit preserves or structured review inventory.
|
|
126
|
+
|
|
127
|
+
## 1.0.0-beta.42
|
|
128
|
+
|
|
129
|
+
## 1.0.0-beta.41
|
|
130
|
+
|
|
131
|
+
## 1.0.0-beta.40
|
|
132
|
+
|
|
133
|
+
### Minor Changes
|
|
134
|
+
|
|
135
|
+
- [`35cbe28`](https://github.com/outfitter-dev/trails/commit/35cbe289db46539b3689dbf6cf8ab0e5d9a1b09c): Found `@ontrails/source` as the shared source-code AST kernel for parsing,
|
|
136
|
+
walking, locations, edits, literals, and generic Trails syntax recognition.
|
|
137
|
+
Warden, Regrade, Wayfinder, and the Trails operator now import those shared
|
|
138
|
+
mechanics from `@ontrails/source`; the legacy Warden AST route is removed by the
|
|
139
|
+
stacked hard cutover.
|
|
140
|
+
|
|
141
|
+
### Patch Changes
|
|
142
|
+
|
|
143
|
+
- [`3531b58`](https://github.com/outfitter-dev/trails/commit/3531b58ba5320753d6d2594257ef71bc950d28a1): Add the advisory captured-kernel Warden rule for ownership review when a public
|
|
144
|
+
subpath re-exports package internals and multiple production workspaces consume
|
|
145
|
+
that subpath, including import-then-export barrels that preserve the internal
|
|
146
|
+
binding through a local alias or default export.
|
|
147
|
+
|
|
148
|
+
Expose typed import-kind inspection from `@ontrails/source` so project rules
|
|
149
|
+
can keep erased type bindings separate from runtime exports.
|
|
150
|
+
|
|
151
|
+
- [`10f2492`](https://github.com/outfitter-dev/trails/commit/10f24928d3bc9d995abf7aa261ecf515c295855d): Own the `wayfind.outline` implementation in the Trails operator app while preserving the existing `trails wayfind file <file> --outline` CLI and MCP composition behavior, and document `@ontrails/source` as the operator's live-source analysis kernel.
|
|
152
|
+
- [`35e5fed`](https://github.com/outfitter-dev/trails/commit/35e5fedd228e498783f479f0dd502e2f3ec772b8): Fold the Wayfinder graph-read catalog into `@ontrails/topography`. Wayfind
|
|
153
|
+
remains the product, trail-id, CLI, and MCP brand, but there is no longer an
|
|
154
|
+
`@ontrails/wayfinder` package to install or import. Programmatic consumers
|
|
155
|
+
should move imports such as `wayfinderTopo`, `wayfindOverviewTrail`,
|
|
156
|
+
`loadWayfinderArtifacts`, and the Wayfinder filter/provenance types to
|
|
157
|
+
`@ontrails/topography`.
|
|
158
|
+
|
|
159
|
+
Expose that package move as a governed Regrade transition so exact
|
|
160
|
+
`@ontrails/wayfinder` imports can move safely while product vocabulary and near
|
|
161
|
+
routes remain unchanged for review. Regrade routes package manifests through
|
|
162
|
+
structured review instead of rewriting dependency keys as plain text.
|
|
163
|
+
|
|
164
|
+
The Trails operator now reads all `wayfind.*` query trails and artifact helpers
|
|
165
|
+
from `@ontrails/topography` while preserving the existing CLI/MCP schemas,
|
|
166
|
+
route IDs, output shapes, and internal trail visibility.
|
|
167
|
+
|
|
168
|
+
- [`3a65ae3`](https://github.com/outfitter-dev/trails/commit/3a65ae363e05b7589f4a9876da4346886353b48c): Rename the durable graph substrate package from `@ontrails/topographer` to
|
|
169
|
+
`@ontrails/topography` after folding Wayfind graph queries into that owner.
|
|
170
|
+
|
|
171
|
+
Update imports to `@ontrails/topography` or
|
|
172
|
+
`@ontrails/topography/backend-support`. The pre-1.0 cutover does not ship a
|
|
173
|
+
compatibility package. TopoGraph, lock, topo-store, semantic diff, and Wayfind
|
|
174
|
+
APIs keep their existing contracts, and the `trails wayfind` CLI and MCP names
|
|
175
|
+
remain unchanged.
|
|
176
|
+
|
|
177
|
+
The governed package-route transition moves legacy `@ontrails/wayfinder`
|
|
178
|
+
imports directly to `@ontrails/topography`; it does not emit the retired
|
|
179
|
+
intermediate `@ontrails/topographer` route.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# @ontrails/source
|
|
2
|
+
|
|
3
|
+
Shared source-code machinery for Trails packages and repo tooling.
|
|
4
|
+
|
|
5
|
+
`source` means source code: TypeScript and JavaScript text parsed into an OXC AST. It does not mean activation source, signal source, data source, event source, or execution source.
|
|
6
|
+
|
|
7
|
+
## What It Owns
|
|
8
|
+
|
|
9
|
+
`@ontrails/source` owns reusable source-code mechanics:
|
|
10
|
+
|
|
11
|
+
- AST node guards and accessors for the OXC node shapes Trails tooling uses.
|
|
12
|
+
- `parse` and `parseWithDiagnostics` wrappers over `oxc-parser`, including
|
|
13
|
+
parser-native comment spans for tools that must distinguish source trivia.
|
|
14
|
+
- `walk`, parent-aware walking, and scope-aware walking over `oxc-walker`.
|
|
15
|
+
- Source locations, source edits, literal extraction, and generic Trails syntax recognition.
|
|
16
|
+
- Generic trail/entity discovery helpers such as `findTrailDefinitions`, `findImplementationBodies`, `findEntityDefinitions`, and `isImplementationCall`.
|
|
17
|
+
|
|
18
|
+
The package root is the public API. Import from `@ontrails/source`; there are no supported `/ast`, `/trails`, or `/utils` subpaths.
|
|
19
|
+
|
|
20
|
+
## Package Admission Test
|
|
21
|
+
|
|
22
|
+
`@ontrails/source` exists because the same source-code contract is reused by independent toolchain owners:
|
|
23
|
+
|
|
24
|
+
- Warden uses it to implement source-static governance without owning the parser facade.
|
|
25
|
+
- Regrade uses it for safe downstream source rewrites.
|
|
26
|
+
- The Trails operator uses it to assemble live source-file outlines.
|
|
27
|
+
- The `trails` operator uses it for draft promotion and version-lifecycle support.
|
|
28
|
+
|
|
29
|
+
The package is admitted only for reusable source machinery with at least two independent toolchain owners and a genuinely shared contract. It must not absorb product verdicts, release plans, query semantics, rendering, Warden rule policy, Regrade engines, Topography artifact assembly, or Wayfinder answer composition.
|
|
30
|
+
|
|
31
|
+
This boundary follows the package-worthiness rule in [ADR-0051: Package Ownership Follows Natural Altitude](../../docs/adr/0051-package-ownership-follows-natural-altitude.md): move code when the natural owner is above one consumer, not when a file merely feels crowded.
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
|
|
35
|
+
Parse source and inspect trail declarations:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { findTrailDefinitions, parse } from '@ontrails/source';
|
|
39
|
+
|
|
40
|
+
const ast = parse(
|
|
41
|
+
'example.ts',
|
|
42
|
+
"import { trail } from '@ontrails/core';\nexport const show = trail('user.show', {});\n"
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const trailIds = ast ? findTrailDefinitions(ast).map((trail) => trail.id) : [];
|
|
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
|
+
|
|
63
|
+
Walk source with parent context:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { parse, walkWithParents } from '@ontrails/source';
|
|
67
|
+
|
|
68
|
+
const ast = parse('example.ts', 'const value = trail("demo.show", {});\n');
|
|
69
|
+
const callParents: string[] = [];
|
|
70
|
+
|
|
71
|
+
if (ast) {
|
|
72
|
+
walkWithParents(ast, (node, context) => {
|
|
73
|
+
if (node.type === 'CallExpression') {
|
|
74
|
+
callParents.push(`${context.parent?.type ?? 'root'}:${String(context.key)}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Apply source edits:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { applySourceEdits, createSourceEdit } from '@ontrails/source';
|
|
84
|
+
|
|
85
|
+
const updated = applySourceEdits('const name = "old";\n', [
|
|
86
|
+
createSourceEdit(14, 17, 'new'),
|
|
87
|
+
]);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Non-Goals
|
|
91
|
+
|
|
92
|
+
- Warden rule policy and rule-specific facts remain in `@ontrails/warden`.
|
|
93
|
+
- Regrade migration planning and execution remain in `@ontrails/regrade`.
|
|
94
|
+
- Topography graph artifacts and outline assembly remain outside this package.
|
|
95
|
+
- Wayfind query trails and answer rendering remain in `@ontrails/topography`.
|
|
96
|
+
- Runtime Trails contracts remain in `@ontrails/core`.
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ontrails/source",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Shared source-code AST parsing, walking, location, edit, literal, and Trails syntax helpers.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/outfitter-dev/trails.git",
|
|
8
|
+
"directory": "packages/source"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/**/*.ts",
|
|
12
|
+
"!src/**/__tests__/**",
|
|
13
|
+
"!src/**/*.test.ts",
|
|
14
|
+
"!src/**/*.test-d.ts",
|
|
15
|
+
"README.md",
|
|
16
|
+
"CHANGELOG.md"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.ts",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -b",
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"lint": "oxlint ./src",
|
|
28
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"oxc-parser": "^0.121.0",
|
|
32
|
+
"oxc-walker": "^1.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
const authoredPath = value.replaceAll('\\', '/');
|
|
153
|
+
const path = posix.normalize(authoredPath).replace(/\/+$/, '');
|
|
154
|
+
if (
|
|
155
|
+
posix.isAbsolute(authoredPath) ||
|
|
156
|
+
path === '.' ||
|
|
157
|
+
path === '..' ||
|
|
158
|
+
path.startsWith('../') ||
|
|
159
|
+
/^[A-Za-z][A-Za-z\d+.-]*:/u.test(path)
|
|
160
|
+
) {
|
|
161
|
+
return { paths: new Set(), readable: false };
|
|
162
|
+
}
|
|
163
|
+
ownersWithPaths.add(owner);
|
|
164
|
+
paths.add(path);
|
|
165
|
+
}
|
|
166
|
+
return owners.size === ownersWithPaths.size &&
|
|
167
|
+
authoredSubmodules.size === ownersWithPaths.size
|
|
168
|
+
? { paths, readable: true }
|
|
169
|
+
: { paths: new Set(), readable: false };
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const boundaryReason = (
|
|
173
|
+
absoluteDirectory: string,
|
|
174
|
+
path: string,
|
|
175
|
+
submodules: SubmodulePathSnapshot
|
|
176
|
+
): SourceCollectionBoundaryReason | 'unreadable-git-boundary' | undefined => {
|
|
177
|
+
if (!submodules.readable) {
|
|
178
|
+
return 'unreadable-git-boundary';
|
|
179
|
+
}
|
|
180
|
+
if (submodules.paths.has(path)) {
|
|
181
|
+
return 'submodule-boundary';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const marker = join(absoluteDirectory, '.git');
|
|
185
|
+
let markerStats: ReturnType<typeof lstatSync>;
|
|
186
|
+
try {
|
|
187
|
+
markerStats = lstatSync(marker);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return (error as NodeJS.ErrnoException).code === 'ENOENT'
|
|
190
|
+
? undefined
|
|
191
|
+
: 'unreadable-git-boundary';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
if (markerStats.isDirectory()) {
|
|
196
|
+
return statSync(join(marker, 'HEAD')).isFile() &&
|
|
197
|
+
statSync(join(marker, 'objects')).isDirectory()
|
|
198
|
+
? 'nested-repository'
|
|
199
|
+
: 'unreadable-git-boundary';
|
|
200
|
+
}
|
|
201
|
+
if (!markerStats.isFile()) {
|
|
202
|
+
return 'unreadable-git-boundary';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const pointer = readFileSync(marker, 'utf8').match(/^gitdir:\s*(.+?)\s*$/);
|
|
206
|
+
const gitDirectory = pointer?.[1];
|
|
207
|
+
if (!gitDirectory) {
|
|
208
|
+
return 'unreadable-git-boundary';
|
|
209
|
+
}
|
|
210
|
+
const resolvedGitDirectory = resolve(absoluteDirectory, gitDirectory);
|
|
211
|
+
return statSync(resolvedGitDirectory).isDirectory() &&
|
|
212
|
+
statSync(join(resolvedGitDirectory, 'HEAD')).isFile()
|
|
213
|
+
? 'nested-worktree'
|
|
214
|
+
: 'unreadable-git-boundary';
|
|
215
|
+
} catch {
|
|
216
|
+
return 'unreadable-git-boundary';
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const comparePath = (
|
|
221
|
+
left: { readonly path: string },
|
|
222
|
+
right: {
|
|
223
|
+
readonly path: string;
|
|
224
|
+
}
|
|
225
|
+
): number => {
|
|
226
|
+
if (left.path < right.path) {
|
|
227
|
+
return -1;
|
|
228
|
+
}
|
|
229
|
+
return left.path > right.path ? 1 : 0;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Collect files from exactly one working tree, pruning nested Git observations.
|
|
234
|
+
*
|
|
235
|
+
* Git boundaries take precedence over consumer policy so a tool cannot scan a
|
|
236
|
+
* nested checkout and hide that fact behind an authored exclude. The supplied
|
|
237
|
+
* root remains first-class: only directories encountered beneath it are
|
|
238
|
+
* classified as boundaries.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```ts
|
|
242
|
+
* const result = collectSourceTree(process.cwd(), {
|
|
243
|
+
* classify: (entry) =>
|
|
244
|
+
* entry.kind === 'file' && entry.path.endsWith('.ts')
|
|
245
|
+
* ? { action: 'collect' }
|
|
246
|
+
* : entry.kind === 'directory'
|
|
247
|
+
* ? { action: 'recurse' }
|
|
248
|
+
* : { action: 'skip', reason: 'unsupported-extension' },
|
|
249
|
+
* });
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
export const collectSourceTree = (
|
|
253
|
+
root: string,
|
|
254
|
+
options: CollectSourceTreeOptions = {}
|
|
255
|
+
): SourceTreeCollection | null => {
|
|
256
|
+
const absoluteRoot = resolve(root);
|
|
257
|
+
let rootEntries: readonly Dirent<string>[];
|
|
258
|
+
try {
|
|
259
|
+
rootEntries = readdirSync(absoluteRoot, { withFileTypes: true });
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const classify = options.classify ?? defaultDecision;
|
|
265
|
+
const submodules = readSubmodulePaths(absoluteRoot);
|
|
266
|
+
const files: CollectedSourceFile[] = [];
|
|
267
|
+
const skipped: SkippedSourceEntry[] = submodules.readable
|
|
268
|
+
? [...submodules.paths].map((path) => ({
|
|
269
|
+
path,
|
|
270
|
+
reason: 'submodule-boundary',
|
|
271
|
+
}))
|
|
272
|
+
: [{ path: '.gitmodules', reason: 'unreadable-git-metadata' }];
|
|
273
|
+
const queue: {
|
|
274
|
+
readonly absolutePath: string;
|
|
275
|
+
readonly entries?: readonly Dirent<string>[];
|
|
276
|
+
}[] = [{ absolutePath: absoluteRoot, entries: rootEntries }];
|
|
277
|
+
|
|
278
|
+
while (queue.length > 0) {
|
|
279
|
+
const current = queue.shift() as (typeof queue)[number];
|
|
280
|
+
let { entries } = current;
|
|
281
|
+
if (!entries) {
|
|
282
|
+
try {
|
|
283
|
+
entries = readdirSync(current.absolutePath, { withFileTypes: true });
|
|
284
|
+
} catch {
|
|
285
|
+
skipped.push({
|
|
286
|
+
path: toPosixRelative(absoluteRoot, current.absolutePath),
|
|
287
|
+
reason: 'unreadable-directory',
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
const absolutePath = join(current.absolutePath, entry.name);
|
|
295
|
+
const path = toPosixRelative(absoluteRoot, absolutePath);
|
|
296
|
+
const kind = entryKind(entry);
|
|
297
|
+
if (path === '.gitmodules' && !submodules.readable) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (submodules.paths.has(path)) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (kind === 'directory') {
|
|
304
|
+
const reason = boundaryReason(absolutePath, path, submodules);
|
|
305
|
+
if (reason) {
|
|
306
|
+
skipped.push({ path, reason });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (entry.name === '.git') {
|
|
311
|
+
skipped.push({ path, reason: 'ignored-directory' });
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const decision = classify({ kind, name: entry.name, path });
|
|
316
|
+
if (decision.action === 'collect') {
|
|
317
|
+
files.push({ absolutePath, path });
|
|
318
|
+
} else if (decision.action === 'recurse') {
|
|
319
|
+
queue.push({ absolutePath });
|
|
320
|
+
} else {
|
|
321
|
+
skipped.push({ path, reason: decision.reason });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
files.sort(comparePath);
|
|
327
|
+
skipped.sort(comparePath);
|
|
328
|
+
return { files, root: absoluteRoot, skipped };
|
|
329
|
+
};
|