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