@rehearsal-db/core 0.1.0-beta.3 → 0.1.0-beta.5

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 CHANGED
@@ -5,6 +5,41 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## [0.1.0-beta.5] - 2026-10-01
9
+
10
+ ### Added
11
+
12
+ - Scalable policy review with explicit table-level safe defaults, suggested structural
13
+ exceptions, per-table summaries, and real-PTY coverage of the bulk-review journey.
14
+ - Bounded project-local discovery for baseline records, migration ledgers, and optional
15
+ Storage manifests, plus a value-free structural preflight before activation.
16
+ - A guided `Get help` action and scriptable `rehearsal support` report with tool
17
+ versions, readiness statuses, privacy guarantees, and a direct bug-report link.
18
+
19
+ ### Changed
20
+
21
+ - Guided policy review can classify ordinary columns in bulk as synthetic replacements
22
+ while keeping likely identifiers, relationships, and timestamps selected for
23
+ individual review. The saved policy still records every required column decision.
24
+ - The baseline guide offers detected inputs instead of requiring memorized paths,
25
+ validates referenced Storage files up front, previews counts without row values, and
26
+ returns to the guide with an actionable message when validation fails.
27
+ - Beta support no longer requires users to assemble environment details by hand; the
28
+ generated report omits project paths, row values, credentials, migration SQL, and
29
+ baseline identifiers and remains available before setup is complete.
30
+
31
+ ## [0.1.0-beta.4] - 2026-10-01
32
+
33
+ ### Added
34
+
35
+ - A persistent guided session with polished terminal prompts, interactive policy review,
36
+ concise runtime receipts, expandable technical details, and real-PTY regression tests.
37
+
38
+ ### Changed
39
+
40
+ - Guided menus now place the next recommended action first and automatically re-inspect
41
+ project state after every completed step while preserving plain and non-TTY modes.
42
+
8
43
  ## [0.1.0-beta.3] - 2026-10-01
9
44
 
10
45
  ### Added
@@ -106,7 +141,9 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
106
141
  publication uses short-lived trusted OIDC, and every release tag must already exist on
107
142
  protected `main`.
108
143
 
109
- [Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.3...HEAD
144
+ [Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.5...HEAD
145
+ [0.1.0-beta.5]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.4...v0.1.0-beta.5
146
+ [0.1.0-beta.4]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.3...v0.1.0-beta.4
110
147
  [0.1.0-beta.3]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.2...v0.1.0-beta.3
111
148
  [0.1.0-beta.2]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.1...v0.1.0-beta.2
112
149
  [0.1.0-beta.1]: https://github.com/Ddupasquier/rehearsal-db/releases/tag/v0.1.0-beta.1
package/README.md CHANGED
@@ -93,6 +93,7 @@ npx rehearsal baseline prepare --records=<safe.ndjson> --ledger=<ledger.json>
93
93
  npx rehearsal baseline prepare --records=<safe.ndjson> --ledger=<ledger.json> --write
94
94
  npx rehearsal baseline create --records=<safe.ndjson> --ledger=<ledger.json>
95
95
  npx rehearsal doctor
96
+ npx rehearsal support
96
97
  npx rehearsal explain
97
98
  npx rehearsal run --dry-run
98
99
  npx rehearsal candidates
@@ -108,8 +109,18 @@ npx rehearsal discard
108
109
  ```
109
110
 
110
111
  Running `npx rehearsal` in a terminal opens a state-aware guide that shows completed
111
- setup steps and recommends available actions. The explicit commands remain the stable
112
- interface for automation and CI.
112
+ setup steps and recommends available actions. The guide stays open after each action,
113
+ re-inspects the project, and advances to the next useful step. It includes an interactive
114
+ policy reviewer with table-level safe defaults and exception-only column review, concise
115
+ completion receipts, optional technical details, and bounded discovery of likely local
116
+ baseline inputs. Before activation it validates records, migration evidence, and optional
117
+ Storage files, then shows a value-free count summary for confirmation. The explicit
118
+ commands remain the stable interface for automation and CI.
119
+
120
+ Choose **Get help** at any stage, or run `npx rehearsal support`, to create a copy-ready
121
+ environment and readiness report for a GitHub issue. It works before setup and omits row
122
+ values, credentials, project paths, migration SQL, and baseline identifiers. Review every
123
+ report before sharing it.
113
124
 
114
125
  `setup` previews a conservative first-run scaffold: the Rehearsal configuration, a
115
126
  dedicated local-only Supabase configuration on an available port block, and protective
package/SUPPORT.md CHANGED
@@ -6,6 +6,11 @@ Docker engine, the failing command, safe diagnostics, and the smallest reproduct
6
6
  The first beta does not yet maintain a separate public discussion or feature-request
7
7
  channel.
8
8
 
9
+ Run `npx rehearsal support` or choose **Get help** in the guide to collect the environment
10
+ and readiness portion without assembling it by hand. The report intentionally excludes
11
+ project paths, row values, credentials, migration SQL, and baseline identifiers. Review
12
+ the report before pasting it into an issue.
13
+
9
14
  Do not include credentials, connection strings, source rows, baseline artifacts, private
10
15
  URLs, or proprietary migrations. Report security problems through private vulnerability
11
16
  reporting as described in [SECURITY.md](SECURITY.md).
package/docs/commands.md CHANGED
@@ -15,6 +15,7 @@ values or credentials.
15
15
  | `rehearsal baseline prepare --records= --ledger= --write` | Policy only | Write the draft without exposing row values. |
16
16
  | `rehearsal baseline create --records=<path> --ledger=<path>` | Artifact only | Activate a baseline from explicit safe local inputs. |
17
17
  | `rehearsal doctor` | No | Check dependencies, inputs, and safety barriers. |
18
+ | `rehearsal support` | No | Print a privacy-safe, copy-ready support report. |
18
19
  | `rehearsal explain` | No | Print the immutable execution plan. |
19
20
  | `rehearsal run --dry-run` | No | Alias the same plan used by `explain`. |
20
21
  | `rehearsal candidates` | No | Print pending migrations and their exact digest. |
@@ -37,8 +38,18 @@ the digest and invalidates either form of confirmation.
37
38
  `baseline create` never extracts data. The NDJSON and migration-ledger files must already
38
39
  exist inside the project and be safe to retain. Add `--assets=<manifest.json>` to include
39
40
  bounded local Storage bytes; every manifest `file` must also remain inside the project.
40
- The guided home screen can ask for these paths so they do not need to be supplied as
41
- flags.
41
+ The guided home screen scans a bounded set of small project-local JSON and NDJSON files,
42
+ skips generated and private runtime directories, and offers structurally matching paths
43
+ so they do not need to be memorized. It never displays row values. Before activation it
44
+ validates the selected files and referenced assets, shows row, table, migration, and asset
45
+ counts, and asks for confirmation. Invalid input returns to the guide without writing.
46
+
47
+ The guide is a persistent session: after setup, policy review, baseline creation, or a
48
+ runtime action it re-reads project state and offers the next relevant action. Generated
49
+ policy drafts can be completed interactively without editing JSON. Each column still
50
+ receives explicit action, generated, identity, and foreign-key decisions. A human may
51
+ apply the displayed safe preset to a table, then review only selected exceptions; the
52
+ guide never silently applies a preset.
42
53
 
43
54
  `baseline prepare` reads only table and column names from the NDJSON records; row values
44
55
  are never included in its result. Its generated policy deliberately marks every column
@@ -50,9 +61,17 @@ Automation should use `--json` and inspect both exit status and the versioned en
50
61
  Exit-code meanings are documented in the root README. Scripts must not parse human text.
51
62
  When standard input or output is not an interactive terminal, bare `rehearsal` prints
52
63
  help instead of prompting. Use `--plain` to disable decorative terminal styling.
64
+ `NO_COLOR` also selects the plain numbered interface. Both interactive modes preserve
65
+ the same safety decisions and cancellation behavior.
53
66
 
54
67
  `setup` chooses an available local port block and generates a conservative Supabase
55
68
  configuration with hosted access and optional networked services disabled. It does not
56
69
  overwrite an existing Rehearsal config, dedicated Supabase config, or concurrently
57
70
  changed `.gitignore`. After writing, it includes a Doctor readiness summary. `init`
58
71
  remains available for config-only/manual onboarding.
72
+
73
+ `support` reports the Rehearsal, Node.js, npm, Supabase CLI, Docker client, and Docker
74
+ server versions plus readiness check statuses. It can run before configuration exists.
75
+ The report deliberately excludes project names and paths, row values, credentials,
76
+ migration SQL, baseline identifiers, and raw command output. It reports only the count
77
+ of quarantined hosted variables, never their names. Always review it before sharing.
@@ -47,7 +47,8 @@ npx rehearsal
47
47
 
48
48
  Choose **Set up Rehearsal** in the guide. It previews a versioned configuration, a
49
49
  dedicated local-only Supabase configuration using available ports, and protective
50
- `.gitignore` entries before asking permission to write.
50
+ `.gitignore` entries before asking permission to write. The guide remains open afterward
51
+ and recommends the next incomplete stage.
51
52
 
52
53
  The same flow is available noninteractively as an explicit preview and write:
53
54
 
@@ -104,7 +105,20 @@ npx rehearsal baseline prepare \
104
105
  --write
105
106
  ```
106
107
 
107
- Review every `REVIEW REQUIRED` field, replace it with correct metadata, and remove
108
+ Running `npx rehearsal` instead discovers structurally matching project-local inputs and
109
+ offers them in the guide. Discovery is bounded, ignores generated/runtime directories,
110
+ and returns paths only. Before a policy draft or immutable baseline is written, Rehearsal
111
+ validates the selected files and presents a value-free shape and count summary.
112
+
113
+ After creating the draft, choose **Review the script** in the guide. For each table,
114
+ choose between reviewing every column or applying the displayed safe defaults and
115
+ reviewing only exceptions. Rehearsal suggests identifiers, relationship columns, and
116
+ timestamps as exceptions. Every saved column still records sanitization action,
117
+ generated status, identity status, and optional foreign-key metadata. The complete
118
+ policy is validated before writing, and the original draft is replaced only if it did
119
+ not change during review.
120
+
121
+ For noninteractive workflows, review every `REVIEW REQUIRED` field directly and remove
108
122
  `"draft": true` only after that review. Rehearsal refuses to activate a draft.
109
123
 
110
124
  Historical migration `supabase/migrations/20260101000000_create_widgets.sql`:
package/docs/releasing.md CHANGED
@@ -37,7 +37,7 @@ publication must change and prove the workflow before narrowing that permission.
37
37
  onboarding. Correct and retest the first confusing, missing, or wrong instruction.
38
38
  5. Change `private` to `false` only in the reviewed release change.
39
39
  6. Record the exact tarball filename, SHA-1, SHA-256, allowlisted files, unpacked size,
40
- executable, and zero-runtime-dependency result.
40
+ executable, declared runtime dependency inventory, and dependency audit result.
41
41
  7. Obtain explicit publication authorization for that exact version and artifact.
42
42
  8. Merge the approved release commit through protected `main` and create the exact
43
43
  `v<package-version>` tag and GitHub release.
@@ -19,6 +19,15 @@ whether it is an identity column, and its foreign-key target or explicit absence
19
19
  draft uses `REVIEW REQUIRED` placeholders and cannot be activated until they are
20
20
  replaced and the `draft` marker is removed.
21
21
 
22
+ In an interactive terminal, the guided **Review the script** action works table by table.
23
+ For larger tables, a human can explicitly apply safe defaults (`REPLACE`, `NEVER`
24
+ generated, `NO` identity, and no foreign key), then review only exceptions. Likely
25
+ structural columns such as `id`, `*_id`, and timestamps start selected as exceptions.
26
+ Users can instead review every column individually. Retaining a value is explicitly
27
+ labeled as sensitive, every saved field remains classified, and no preset is silently
28
+ applied. The reviewer validates the completed policy and refuses to overwrite a draft
29
+ changed during the session.
30
+
22
31
  The package exports `validateSanitizationCoverage` and
23
32
  `applySanitizationAction` as generic primitives:
24
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehearsal-db/core",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.5",
4
4
  "private": false,
5
5
  "description": "Safely rehearse Supabase migrations against sanitized, production-shaped PostgreSQL data.",
6
6
  "repository": {
@@ -69,9 +69,12 @@
69
69
  "database"
70
70
  ],
71
71
  "license": "MIT",
72
- "dependencies": {},
72
+ "dependencies": {
73
+ "@clack/prompts": "^1.8.1"
74
+ },
73
75
  "devDependencies": {
76
+ "@lydell/node-pty": "^1.2.0-beta.15",
74
77
  "prettier": "^3.9.6",
75
- "vitest": "^4.1.10"
78
+ "vitest": "^5.0.2"
76
79
  }
77
80
  }
@@ -4,13 +4,14 @@
4
4
  */
5
5
 
6
6
  import { createReadStream } from "node:fs";
7
- import { access, mkdir, open, readFile } from "node:fs/promises";
7
+ import { access, lstat, mkdir, open, readFile } from "node:fs/promises";
8
8
  import { createInterface } from "node:readline";
9
9
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
10
10
  import { loadRehearsalConfig } from "./configuration.mjs";
11
11
  import { buildMigrationLedgerInventory } from "./migration_history.mjs";
12
12
 
13
13
  const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
14
+ const storageBucketPattern = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
14
15
 
15
16
  const resolveProjectInput = (projectRoot, value, label) => {
16
17
  if (typeof value !== "string" || value.trim() === "") {
@@ -37,6 +38,19 @@ const pathExists = (path) =>
37
38
  throw error;
38
39
  });
39
40
 
41
+ const assertRegularInput = async (path, label) => {
42
+ let stats;
43
+ try {
44
+ stats = await lstat(path);
45
+ } catch (error) {
46
+ if (error?.code === "ENOENT") throw new Error(`${label} does not exist.`);
47
+ throw error;
48
+ }
49
+ if (stats.isSymbolicLink() || !stats.isFile()) {
50
+ throw new Error(`${label} must be a regular project-local file.`);
51
+ }
52
+ };
53
+
40
54
  const inspectSyntheticRecords = async (path) => {
41
55
  const tables = new Map();
42
56
  let rowCount = 0;
@@ -97,6 +111,120 @@ const inspectSyntheticRecords = async (path) => {
97
111
  };
98
112
  };
99
113
 
114
+ const inspectAssetManifest = async (path, projectRoot) => {
115
+ let manifest;
116
+ try {
117
+ manifest = JSON.parse(await readFile(path, "utf8"));
118
+ } catch (error) {
119
+ throw new Error("Storage asset manifest contains invalid JSON.", {
120
+ cause: error,
121
+ });
122
+ }
123
+ if (!Array.isArray(manifest)) {
124
+ throw new Error("Storage asset manifest must be a JSON array.");
125
+ }
126
+ const destinations = new Set();
127
+ for (const [index, asset] of manifest.entries()) {
128
+ if (
129
+ !asset ||
130
+ typeof asset !== "object" ||
131
+ Array.isArray(asset) ||
132
+ !storageBucketPattern.test(asset.bucket ?? "") ||
133
+ typeof asset.objectPath !== "string" ||
134
+ !asset.objectPath ||
135
+ asset.objectPath
136
+ .split("/")
137
+ .some((segment) => !segment || segment === "." || segment === "..") ||
138
+ typeof asset.file !== "string" ||
139
+ !asset.file.trim() ||
140
+ (asset.contentType !== undefined && typeof asset.contentType !== "string")
141
+ ) {
142
+ throw new Error(
143
+ `Storage asset manifest entry ${index + 1} has an invalid shape.`,
144
+ );
145
+ }
146
+ const destination = `${asset.bucket}/${asset.objectPath}`;
147
+ if (destinations.has(destination)) {
148
+ throw new Error(`Duplicate Storage asset destination: ${destination}.`);
149
+ }
150
+ destinations.add(destination);
151
+ const assetFile = resolveProjectInput(
152
+ projectRoot,
153
+ asset.file,
154
+ `Storage asset file in entry ${index + 1}`,
155
+ );
156
+ await assertRegularInput(
157
+ assetFile,
158
+ `Storage asset file in entry ${index + 1}`,
159
+ );
160
+ }
161
+ return { assetCount: manifest.length };
162
+ };
163
+
164
+ export const inspectBaselineInputFiles = async ({
165
+ projectRoot = process.cwd(),
166
+ configPath,
167
+ recordsPath,
168
+ ledgerPath,
169
+ assetsPath,
170
+ }) => {
171
+ const loaded = await loadRehearsalConfig({ projectRoot, configPath });
172
+ const records = resolveProjectInput(
173
+ loaded.projectRoot,
174
+ recordsPath,
175
+ "Synthetic records path",
176
+ );
177
+ const ledger = resolveProjectInput(
178
+ loaded.projectRoot,
179
+ ledgerPath,
180
+ "Migration ledger path",
181
+ );
182
+ const assets = assetsPath
183
+ ? resolveProjectInput(
184
+ loaded.projectRoot,
185
+ assetsPath,
186
+ "Storage asset manifest path",
187
+ )
188
+ : undefined;
189
+ await Promise.all([
190
+ assertRegularInput(records, "Synthetic records path"),
191
+ assertRegularInput(ledger, "Migration ledger path"),
192
+ ...(assets
193
+ ? [assertRegularInput(assets, "Storage asset manifest path")]
194
+ : []),
195
+ ]);
196
+ let ledgerRows;
197
+ try {
198
+ ledgerRows = JSON.parse(await readFile(ledger, "utf8"));
199
+ } catch (error) {
200
+ throw new Error("Migration ledger contains invalid JSON.", {
201
+ cause: error,
202
+ });
203
+ }
204
+ const [inspection, assetInspection] = await Promise.all([
205
+ inspectSyntheticRecords(records),
206
+ assets
207
+ ? inspectAssetManifest(assets, loaded.projectRoot)
208
+ : Promise.resolve({ assetCount: 0 }),
209
+ ]);
210
+ const migrationHistory = buildMigrationLedgerInventory(
211
+ ledgerRows,
212
+ "Synthetic migration ledger",
213
+ );
214
+ return {
215
+ projectRoot: loaded.projectRoot,
216
+ recordsPath: relative(loaded.projectRoot, records),
217
+ ledgerPath: relative(loaded.projectRoot, ledger),
218
+ assetsPath: assets ? relative(loaded.projectRoot, assets) : undefined,
219
+ migrationHistory,
220
+ migrationCutoff: migrationHistory.at(-1).version,
221
+ migrationCount: migrationHistory.length,
222
+ rowCount: inspection.rowCount,
223
+ tables: inspection.tables,
224
+ assetCount: assetInspection.assetCount,
225
+ };
226
+ };
227
+
100
228
  const renderPolicyDraft = ({ migrationCutoff, tables }) =>
101
229
  `${JSON.stringify(
102
230
  {
@@ -127,43 +255,30 @@ export const planBaselinePreparation = async ({
127
255
  ledgerPath,
128
256
  }) => {
129
257
  const loaded = await loadRehearsalConfig({ projectRoot, configPath });
130
- const records = resolveProjectInput(
131
- loaded.projectRoot,
258
+ const inspection = await inspectBaselineInputFiles({
259
+ projectRoot: loaded.projectRoot,
260
+ configPath,
132
261
  recordsPath,
133
- "Synthetic records path",
134
- );
135
- const ledger = resolveProjectInput(
136
- loaded.projectRoot,
137
262
  ledgerPath,
138
- "Migration ledger path",
139
- );
140
- const [inspection, ledgerRows] = await Promise.all([
141
- inspectSyntheticRecords(records),
142
- readFile(ledger, "utf8").then(JSON.parse),
143
- ]);
144
- const migrationHistory = buildMigrationLedgerInventory(
145
- ledgerRows,
146
- "Synthetic migration ledger",
147
- );
263
+ });
148
264
  const destination = loaded.paths.sanitizationPolicy;
149
265
  if (await pathExists(destination)) {
150
266
  throw new Error(
151
267
  `Rehearsal baseline preparation will not overwrite ${relative(loaded.projectRoot, destination)}.`,
152
268
  );
153
269
  }
154
- const migrationCutoff = migrationHistory.at(-1).version;
155
270
  return {
156
271
  projectRoot: loaded.projectRoot,
157
272
  destination,
158
273
  destinationRelative: relative(loaded.projectRoot, destination),
159
- recordsPath: relative(loaded.projectRoot, records),
160
- ledgerPath: relative(loaded.projectRoot, ledger),
161
- migrationCutoff,
162
- migrationCount: migrationHistory.length,
274
+ recordsPath: inspection.recordsPath,
275
+ ledgerPath: inspection.ledgerPath,
276
+ migrationCutoff: inspection.migrationCutoff,
277
+ migrationCount: inspection.migrationCount,
163
278
  rowCount: inspection.rowCount,
164
279
  tables: inspection.tables,
165
280
  content: renderPolicyDraft({
166
- migrationCutoff,
281
+ migrationCutoff: inspection.migrationCutoff,
167
282
  tables: inspection.tables,
168
283
  }),
169
284
  };
@@ -7,7 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
 
9
9
  export const REHEARSAL_RESULT_VERSION = 1;
10
- export const REHEARSAL_VERSION = "0.1.0-beta.3";
10
+ export const REHEARSAL_VERSION = "0.1.0-beta.5";
11
11
 
12
12
  export const REHEARSAL_EXIT_CODES = Object.freeze({
13
13
  success: 0,
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Discover likely project-owned baseline inputs without exposing their values.
3
+ * Discovery is bounded, ignores generated/private directories, and only sniffs
4
+ * small JSON or NDJSON files before returning project-relative paths.
5
+ */
6
+
7
+ import { open, readFile, readdir, stat } from "node:fs/promises";
8
+ import { extname, join, relative, sep } from "node:path";
9
+
10
+ const ignoredDirectories = new Set([
11
+ ".git",
12
+ ".next",
13
+ ".rehearsal",
14
+ ".turbo",
15
+ "build",
16
+ "coverage",
17
+ "dist",
18
+ "node_modules",
19
+ ]);
20
+ const maximumDepth = 4;
21
+ const maximumFiles = 2_000;
22
+ const maximumJsonBytes = 2 * 1024 * 1024;
23
+ const maximumNdjsonProbeBytes = 64 * 1024;
24
+
25
+ const projectRelativePath = (projectRoot, path) =>
26
+ relative(projectRoot, path).split(sep).join("/");
27
+
28
+ const preference = {
29
+ records: [
30
+ "rehearsal/sanitized-data.ndjson",
31
+ "rehearsal/synthetic-data.ndjson",
32
+ ],
33
+ ledgers: ["rehearsal/migration-ledger.json"],
34
+ assetManifests: ["rehearsal/assets.json"],
35
+ };
36
+
37
+ const sortCandidates = (kind, candidates) => {
38
+ const preferred = new Map(
39
+ preference[kind].map((path, index) => [path, index]),
40
+ );
41
+ return candidates.sort((left, right) => {
42
+ const leftRank = preferred.get(left) ?? Number.POSITIVE_INFINITY;
43
+ const rightRank = preferred.get(right) ?? Number.POSITIVE_INFINITY;
44
+ return leftRank - rightRank || left.localeCompare(right);
45
+ });
46
+ };
47
+
48
+ const readFirstRecord = async (path) => {
49
+ const handle = await open(path, "r");
50
+ try {
51
+ const buffer = Buffer.alloc(maximumNdjsonProbeBytes);
52
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
53
+ const probe = buffer.subarray(0, bytesRead).toString("utf8");
54
+ const lines = probe.split(/\r?\n/u);
55
+ for (const line of lines) {
56
+ if (!line.trim()) continue;
57
+ return JSON.parse(line);
58
+ }
59
+ } finally {
60
+ await handle.close();
61
+ }
62
+ return undefined;
63
+ };
64
+
65
+ const isRecord = (value) =>
66
+ Boolean(
67
+ value &&
68
+ typeof value === "object" &&
69
+ !Array.isArray(value) &&
70
+ typeof value.table === "string" &&
71
+ value.row &&
72
+ typeof value.row === "object" &&
73
+ !Array.isArray(value.row),
74
+ );
75
+
76
+ const isMigrationLedger = (value) =>
77
+ Array.isArray(value) &&
78
+ value.length > 0 &&
79
+ value.every(
80
+ (entry) =>
81
+ entry &&
82
+ typeof entry === "object" &&
83
+ typeof entry.version === "string" &&
84
+ typeof entry.name === "string" &&
85
+ Array.isArray(entry.statements),
86
+ );
87
+
88
+ const isAssetManifest = (value) =>
89
+ Array.isArray(value) &&
90
+ value.length > 0 &&
91
+ value.every(
92
+ (entry) =>
93
+ entry &&
94
+ typeof entry === "object" &&
95
+ typeof entry.bucket === "string" &&
96
+ typeof entry.objectPath === "string" &&
97
+ typeof entry.file === "string",
98
+ );
99
+
100
+ const collectCandidateFiles = async (projectRoot) => {
101
+ const files = [];
102
+ const visit = async (directory, depth) => {
103
+ if (depth > maximumDepth || files.length >= maximumFiles) return;
104
+ const entries = await readdir(directory, { withFileTypes: true }).catch(
105
+ () => [],
106
+ );
107
+ entries.sort((left, right) => left.name.localeCompare(right.name));
108
+ for (const entry of entries) {
109
+ if (files.length >= maximumFiles) return;
110
+ if (entry.isSymbolicLink()) continue;
111
+ const path = join(directory, entry.name);
112
+ if (entry.isDirectory()) {
113
+ if (!ignoredDirectories.has(entry.name)) await visit(path, depth + 1);
114
+ continue;
115
+ }
116
+ if (!entry.isFile() || ![".json", ".ndjson"].includes(extname(path))) {
117
+ continue;
118
+ }
119
+ files.push(path);
120
+ }
121
+ };
122
+ await visit(projectRoot, 0);
123
+ return files;
124
+ };
125
+
126
+ export const discoverBaselineInputFiles = async ({
127
+ projectRoot = process.cwd(),
128
+ } = {}) => {
129
+ const result = { records: [], ledgers: [], assetManifests: [] };
130
+ for (const path of await collectCandidateFiles(projectRoot)) {
131
+ const pathStats = await stat(path).catch(() => undefined);
132
+ if (!pathStats?.isFile()) continue;
133
+ const relativePath = projectRelativePath(projectRoot, path);
134
+ try {
135
+ if (extname(path) === ".ndjson") {
136
+ if (isRecord(await readFirstRecord(path))) {
137
+ result.records.push(relativePath);
138
+ }
139
+ continue;
140
+ }
141
+ if (pathStats.size > maximumJsonBytes) continue;
142
+ const value = JSON.parse(await readFile(path, "utf8"));
143
+ if (isMigrationLedger(value)) result.ledgers.push(relativePath);
144
+ else if (isAssetManifest(value)) result.assetManifests.push(relativePath);
145
+ } catch {
146
+ // Invalid or unreadable files are not candidates; explicit validation will
147
+ // provide actionable errors if the user enters one manually.
148
+ }
149
+ }
150
+ return {
151
+ records: sortCandidates("records", result.records),
152
+ ledgers: sortCandidates("ledgers", result.ledgers),
153
+ assetManifests: sortCandidates("assetManifests", result.assetManifests),
154
+ };
155
+ };