@davmash/bygone 0.5.2 → 0.7.1

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.
@@ -0,0 +1,321 @@
1
+ const { CLI_SPEC, getCliEntry, tokensFor } = require('./commandSpec.js');
2
+
3
+ const SUPPORTED_SHELLS = Object.freeze(['zsh', 'bash', 'fish']);
4
+
5
+ function generateCompletion(shell) {
6
+ if (shell === 'zsh') {
7
+ return generateZshCompletion();
8
+ }
9
+ if (shell === 'bash') {
10
+ return generateBashCompletion();
11
+ }
12
+ if (shell === 'fish') {
13
+ return generateFishCompletion();
14
+ }
15
+ throw new Error(`Unsupported shell "${shell}". Choose: ${SUPPORTED_SHELLS.join(', ')}.`);
16
+ }
17
+
18
+ function completionFileName(shell) {
19
+ if (shell === 'zsh') {
20
+ return '_bygone';
21
+ }
22
+ if (shell === 'bash') {
23
+ return 'bygone';
24
+ }
25
+ if (shell === 'fish') {
26
+ return 'bygone.fish';
27
+ }
28
+ throw new Error(`Unsupported shell "${shell}".`);
29
+ }
30
+
31
+ function generateZshCompletion() {
32
+ const rootItems = completionEntries()
33
+ .flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
34
+ .join('\n');
35
+ const reviewOptions = entriesByIds(['base', 'help'])
36
+ .flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
37
+ .join('\n');
38
+ const presentOptions = entriesByIds(['base', 'tour', 'help'])
39
+ .flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
40
+ .join('\n');
41
+ const legacyOptions = entriesByIds(['branch', 'base', 'help'])
42
+ .flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
43
+ .join('\n');
44
+ const historyOptions = entriesByIds(['includeStaged', 'help'])
45
+ .flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
46
+ .join('\n');
47
+
48
+ return `#compdef bygone
49
+ # Generated from cli/commandSpec.js. Do not edit by hand.
50
+
51
+ _bygone_git_refs() {
52
+ local -a refs
53
+ refs=(\${(f)"$(git for-each-ref --format='%(refname:short)' refs/heads refs/remotes refs/tags 2>/dev/null)"})
54
+ _describe 'git source' refs
55
+ compadd -- HEAD INDEX WORKTREE
56
+ }
57
+
58
+ _bygone() {
59
+ local first="\${words[2]}"
60
+ local previous="\${words[CURRENT-1]}"
61
+ local -a root_items review_options present_options legacy_options history_options shells tour_actions
62
+ root_items=(
63
+ ${rootItems}
64
+ )
65
+ review_options=(
66
+ ${reviewOptions}
67
+ )
68
+ present_options=(
69
+ ${presentOptions}
70
+ )
71
+ legacy_options=(
72
+ ${legacyOptions}
73
+ )
74
+ history_options=(
75
+ ${historyOptions}
76
+ )
77
+ shells=('zsh:Z shell' 'bash:Bash' 'fish:Fish shell')
78
+ tour_actions=('context:Build structured change evidence' 'validate:Validate source and anchors' 'compile:Compile a portable manifest' 'schema:Print the source schema')
79
+
80
+ if (( CURRENT == 2 )); then
81
+ _describe 'bygone command' root_items
82
+ _files
83
+ return
84
+ fi
85
+
86
+ case "$first" in
87
+ completion)
88
+ _describe 'shell' shells
89
+ ;;
90
+ tour)
91
+ if (( CURRENT == 3 )); then
92
+ _describe 'tour action' tour_actions
93
+ elif [[ "$words[3]" == "context" ]]; then
94
+ if [[ "$previous" == ${shellAlternation(tokensFor('base'))} ]]; then
95
+ _bygone_git_refs
96
+ elif [[ "$previous" == "--output" || "$previous" == "-o" ]]; then
97
+ _files
98
+ else
99
+ compadd -- --base --output -o --max-patch-bytes --max-total-patch-bytes
100
+ _bygone_git_refs
101
+ fi
102
+ elif [[ "$words[3]" == "validate" || "$words[3]" == "compile" ]]; then
103
+ _files -g '*.bygone.yaml'
104
+ fi
105
+ ;;
106
+ review)
107
+ if [[ "$previous" == ${shellAlternation(tokensFor('base'))} ]]; then
108
+ _bygone_git_refs
109
+ else
110
+ _describe 'review option' review_options
111
+ _bygone_git_refs
112
+ fi
113
+ ;;
114
+ present)
115
+ if [[ "$previous" == ${shellAlternation(tokensFor('tour'))} ]]; then
116
+ _files -g '*.bygone.yaml'
117
+ elif [[ "$previous" == ${shellAlternation(tokensFor('base'))} ]]; then
118
+ _bygone_git_refs
119
+ else
120
+ _describe 'present option' present_options
121
+ _bygone_git_refs
122
+ fi
123
+ ;;
124
+ --branch-diff)
125
+ if [[ "$previous" == ${shellAlternation([...tokensFor('branch'), ...tokensFor('base')])} ]]; then
126
+ _bygone_git_refs
127
+ else
128
+ _describe 'review option' legacy_options
129
+ _bygone_git_refs
130
+ fi
131
+ ;;
132
+ --git-diff)
133
+ _bygone_git_refs
134
+ ;;
135
+ --history)
136
+ _describe 'history option' history_options
137
+ _files
138
+ ;;
139
+ --diff)
140
+ _files
141
+ ;;
142
+ *)
143
+ _files
144
+ ;;
145
+ esac
146
+ }
147
+
148
+ compdef _bygone bygone
149
+ `;
150
+ }
151
+
152
+ function generateBashCompletion() {
153
+ const rootTokens = completionEntries().flatMap((entry) => entry.tokens).join(' ');
154
+ const reviewTokens = entriesByIds(['base', 'help']).flatMap((entry) => entry.tokens).join(' ');
155
+ const presentTokens = entriesByIds(['base', 'tour', 'help']).flatMap((entry) => entry.tokens).join(' ');
156
+ const legacyTokens = entriesByIds(['branch', 'base', 'help']).flatMap((entry) => entry.tokens).join(' ');
157
+ const historyTokens = entriesByIds(['includeStaged', 'help']).flatMap((entry) => entry.tokens).join(' ');
158
+ const baseTokens = tokensFor('base').join('|');
159
+ const refTokens = [...tokensFor('branch'), ...tokensFor('base')].join('|');
160
+
161
+ return `# Generated from cli/commandSpec.js. Do not edit by hand.
162
+ _bygone_git_refs() {
163
+ git for-each-ref --format='%(refname:short)' refs/heads refs/remotes refs/tags 2>/dev/null
164
+ printf '%s\\n' HEAD INDEX WORKTREE
165
+ }
166
+
167
+ _bygone() {
168
+ local cur prev first refs
169
+ COMPREPLY=()
170
+ cur="\${COMP_WORDS[COMP_CWORD]}"
171
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
172
+ first="\${COMP_WORDS[1]}"
173
+
174
+ if (( COMP_CWORD == 1 )); then
175
+ COMPREPLY=( $(compgen -W '${rootTokens}' -- "$cur") $(compgen -f -- "$cur") )
176
+ compopt -o filenames 2>/dev/null || true
177
+ return
178
+ fi
179
+
180
+ case "$first" in
181
+ completion)
182
+ COMPREPLY=( $(compgen -W 'zsh bash fish' -- "$cur") )
183
+ ;;
184
+ tour)
185
+ if (( COMP_CWORD == 2 )); then
186
+ COMPREPLY=( $(compgen -W 'context validate compile schema' -- "$cur") )
187
+ elif [[ "\${COMP_WORDS[2]}" == "context" ]]; then
188
+ refs="$(_bygone_git_refs)"
189
+ if [[ "$prev" =~ ^(${baseTokens})$ ]]; then
190
+ COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
191
+ elif [[ "$prev" == "--output" || "$prev" == "-o" ]]; then
192
+ COMPREPLY=( $(compgen -f -- "$cur") )
193
+ compopt -o filenames 2>/dev/null || true
194
+ else
195
+ COMPREPLY=( $(compgen -W '--base --output -o --max-patch-bytes --max-total-patch-bytes '"$refs" -- "$cur") )
196
+ fi
197
+ else
198
+ COMPREPLY=( $(compgen -W '--json --output -o' -- "$cur") $(compgen -f -- "$cur") )
199
+ compopt -o filenames 2>/dev/null || true
200
+ fi
201
+ ;;
202
+ review)
203
+ refs="$(_bygone_git_refs)"
204
+ if [[ "$prev" =~ ^(${baseTokens})$ ]]; then
205
+ COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
206
+ else
207
+ COMPREPLY=( $(compgen -W '${reviewTokens} '"$refs" -- "$cur") )
208
+ fi
209
+ ;;
210
+ present)
211
+ refs="$(_bygone_git_refs)"
212
+ if [[ "$prev" == "--tour" ]]; then
213
+ COMPREPLY=( $(compgen -f -X '!*.bygone.yaml' -- "$cur") )
214
+ compopt -o filenames 2>/dev/null || true
215
+ elif [[ "$prev" =~ ^(${baseTokens})$ ]]; then
216
+ COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
217
+ else
218
+ COMPREPLY=( $(compgen -W '${presentTokens} '"$refs" -- "$cur") )
219
+ fi
220
+ ;;
221
+ --branch-diff)
222
+ refs="$(_bygone_git_refs)"
223
+ if [[ "$prev" =~ ^(${refTokens})$ ]]; then
224
+ COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
225
+ else
226
+ COMPREPLY=( $(compgen -W '${legacyTokens} '"$refs" -- "$cur") )
227
+ fi
228
+ ;;
229
+ --git-diff)
230
+ COMPREPLY=( $(compgen -W "$(_bygone_git_refs)" -- "$cur") )
231
+ ;;
232
+ --history)
233
+ COMPREPLY=( $(compgen -W '${historyTokens}' -- "$cur") $(compgen -f -- "$cur") )
234
+ compopt -o filenames 2>/dev/null || true
235
+ ;;
236
+ *)
237
+ COMPREPLY=( $(compgen -f -- "$cur") )
238
+ compopt -o filenames 2>/dev/null || true
239
+ ;;
240
+ esac
241
+ }
242
+
243
+ complete -F _bygone bygone
244
+ `;
245
+ }
246
+
247
+ function generateFishCompletion() {
248
+ const lines = [
249
+ '# Generated from cli/commandSpec.js. Do not edit by hand.',
250
+ 'function __bygone_git_refs',
251
+ " git for-each-ref --format='%(refname:short)' refs/heads refs/remotes refs/tags 2>/dev/null",
252
+ ' printf "%s\\n" HEAD INDEX WORKTREE',
253
+ 'end',
254
+ ''
255
+ ];
256
+
257
+ for (const entry of completionEntries()) {
258
+ const option = fishTokens(entry.tokens);
259
+ const argument = entry.argument === 'path' || entry.argument === 'files' ? ' -r' : '';
260
+ lines.push(`complete -c bygone -n '__fish_use_subcommand' ${option}${argument} -d '${escapeSingleQuoted(entry.description)}'`);
261
+ }
262
+
263
+ lines.push(
264
+ "complete -c bygone -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish' -d 'Shell'",
265
+ "complete -c bygone -n '__fish_seen_subcommand_from tour' -a 'context validate compile schema' -d 'Tour action'",
266
+ "complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l base -r -a '(__bygone_git_refs)' -d 'Set the change-context base ref'",
267
+ "complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l output -s o -r -d 'Write the change context'",
268
+ "complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l max-patch-bytes -r -d 'Maximum included patch size per file'",
269
+ "complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l max-total-patch-bytes -r -d 'Maximum included patch size for the dossier'",
270
+ "complete -c bygone -n 'string match -q \"*tour validate*\" -- (commandline -opc)' -l json -d 'Print machine-readable validation output'",
271
+ "complete -c bygone -n 'string match -q \"*tour compile*\" -- (commandline -opc)' -l output -s o -r -d 'Write the compiled manifest'",
272
+ `complete -c bygone -n 'string match -q "*--history*" -- (commandline -opc)' ${fishTokens(tokensFor('includeStaged'))} -d '${escapeSingleQuoted(getCliEntry('includeStaged').description)}'`,
273
+ `complete -c bygone -n '__fish_seen_subcommand_from review' ${fishTokens(tokensFor('base'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('base').description)}'`,
274
+ "complete -c bygone -n '__fish_seen_subcommand_from review' -a '(__bygone_git_refs)' -d 'Git ref'",
275
+ `complete -c bygone -n '__fish_seen_subcommand_from present' ${fishTokens(tokensFor('base'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('base').description)}'`,
276
+ `complete -c bygone -n '__fish_seen_subcommand_from present' ${fishTokens(tokensFor('tour'))} -r -d '${escapeSingleQuoted(getCliEntry('tour').description)}'`,
277
+ "complete -c bygone -n '__fish_seen_subcommand_from present' -a '(__bygone_git_refs)' -d 'Git ref'",
278
+ `complete -c bygone -n 'string match -q "*--branch-diff*" -- (commandline -opc)' ${fishTokens(tokensFor('branch'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('branch').description)}'`,
279
+ `complete -c bygone -n 'string match -q "*--branch-diff*" -- (commandline -opc)' ${fishTokens(tokensFor('base'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('base').description)}'`,
280
+ `complete -c bygone -n 'string match -q "*--git-diff*" -- (commandline -opc)' -a '(__bygone_git_refs)' -d 'Git source'`
281
+ );
282
+ return `${lines.join('\n')}\n`;
283
+ }
284
+
285
+ function completionEntries() {
286
+ return CLI_SPEC.entries.filter((entry) => (
287
+ entry.kind === 'command' || entry.kind === 'mode' || entry.kind === 'global'
288
+ ));
289
+ }
290
+
291
+ function entriesByIds(ids) {
292
+ return ids.map((id) => getCliEntry(id)).filter(Boolean);
293
+ }
294
+
295
+ function shellAlternation(tokens) {
296
+ return `(${tokens.join('|')})`;
297
+ }
298
+
299
+ function fishTokenOption(token) {
300
+ if (token.startsWith('--')) {
301
+ return `-l ${token.slice(2)}`;
302
+ }
303
+ if (token.startsWith('-')) {
304
+ return `-s ${token.slice(1)}`;
305
+ }
306
+ return `-a ${token}`;
307
+ }
308
+
309
+ function fishTokens(tokens) {
310
+ return tokens.map(fishTokenOption).join(' ');
311
+ }
312
+
313
+ function escapeSingleQuoted(value) {
314
+ return String(value).replace(/'/g, "'\\''");
315
+ }
316
+
317
+ module.exports = {
318
+ SUPPORTED_SHELLS,
319
+ completionFileName,
320
+ generateCompletion
321
+ };
package/cli/present.js ADDED
@@ -0,0 +1,166 @@
1
+ const { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs');
2
+ const { createServer } = require('http');
3
+ const path = require('path');
4
+ const { spawn } = require('child_process');
5
+ const { buildChangeTourManifest, parseChangeTourStory } = require('../out/changeTour.js');
6
+ const { tokenMatches } = require('./commandSpec.js');
7
+ const { loadTourSource } = require('./tourFile.js');
8
+
9
+ const MIME_TYPES = new Map([
10
+ ['.css', 'text/css; charset=utf-8'],
11
+ ['.html', 'text/html; charset=utf-8'],
12
+ ['.js', 'application/javascript; charset=utf-8'],
13
+ ['.json', 'application/json; charset=utf-8'],
14
+ ['.png', 'image/png'],
15
+ ['.svg', 'image/svg+xml; charset=utf-8'],
16
+ ['.ttf', 'font/ttf']
17
+ ]);
18
+
19
+ async function startPresentation(args, cwd, packageRoot, options = {}) {
20
+ const { headRef, baseRef, tourPath, explicitHeadRef } = parsePresentArgs(args);
21
+ const story = process.env.BYGONE_TOUR_STORY
22
+ ? parseChangeTourStory(JSON.parse(readFileSync(path.resolve(cwd, process.env.BYGONE_TOUR_STORY), 'utf8')))
23
+ : undefined;
24
+ const source = tourPath ? loadTourSource(cwd, tourPath).source : undefined;
25
+ const manifest = buildChangeTourManifest(cwd, {
26
+ headRef: explicitHeadRef || source?.range?.head || headRef,
27
+ baseRef: baseRef || source?.range?.base,
28
+ title: process.env.BYGONE_TOUR_TITLE,
29
+ sourceUrl: process.env.BYGONE_TOUR_SOURCE_URL,
30
+ story,
31
+ source
32
+ });
33
+ const serializedManifest = `${JSON.stringify(manifest, null, 2)}\n`;
34
+ const outputPath = process.env.BYGONE_TOUR_OUTPUT;
35
+ if (outputPath) {
36
+ const resolvedOutput = path.resolve(cwd, outputPath);
37
+ mkdirSync(path.dirname(resolvedOutput), { recursive: true });
38
+ writeFileSync(resolvedOutput, serializedManifest, 'utf8');
39
+ process.stdout.write(`Wrote change-tour manifest to ${resolvedOutput}\n`);
40
+ }
41
+
42
+ const server = createServer((request, response) => {
43
+ const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
44
+ if (requestUrl.pathname === '/tour.json') {
45
+ response.writeHead(200, {
46
+ 'Content-Type': 'application/json; charset=utf-8',
47
+ 'Cache-Control': 'no-store'
48
+ });
49
+ response.end(serializedManifest);
50
+ return;
51
+ }
52
+ const targetPath = resolveAssetPath(packageRoot, requestUrl.pathname);
53
+ if (!targetPath) {
54
+ respond(response, 404, 'Not found');
55
+ return;
56
+ }
57
+ response.writeHead(200, {
58
+ 'Content-Type': MIME_TYPES.get(path.extname(targetPath)) || 'application/octet-stream',
59
+ 'Cache-Control': 'no-store'
60
+ });
61
+ createReadStream(targetPath).on('error', () => respond(response, 500, 'Read failed')).pipe(response);
62
+ });
63
+ const requestedPort = readPort(process.env.BYGONE_TOUR_PORT);
64
+ await new Promise((resolve, reject) => {
65
+ server.once('error', reject);
66
+ server.listen(requestedPort, '127.0.0.1', resolve);
67
+ });
68
+ const address = server.address();
69
+ if (!address || typeof address === 'string') {
70
+ server.close();
71
+ throw new Error('Could not determine the presentation server address.');
72
+ }
73
+ const url = `http://127.0.0.1:${address.port}/?manifest=/tour.json`;
74
+ if (options.announce !== false) {
75
+ process.stdout.write(`Bygone change tour running at ${url}\n`);
76
+ process.stdout.write('Press Ctrl+C to stop.\n');
77
+ }
78
+ if (options.open !== false && process.env.BYGONE_TOUR_NO_OPEN !== '1') {
79
+ const openUrl = typeof options.openUrl === 'function' ? options.openUrl : openBrowser;
80
+ try {
81
+ await openUrl(url);
82
+ } catch (error) {
83
+ server.close();
84
+ throw error;
85
+ }
86
+ }
87
+ return { manifest, server, url };
88
+ }
89
+
90
+ function parsePresentArgs(args) {
91
+ let headRef = 'HEAD';
92
+ let explicitHeadRef;
93
+ let baseRef;
94
+ let tourPath;
95
+ for (let index = 0; index < args.length; index += 1) {
96
+ const arg = args[index];
97
+ if (tokenMatches('base', arg)) {
98
+ if (!args[index + 1]) {
99
+ throw new Error(`${arg} requires a Git ref.`);
100
+ }
101
+ baseRef = args[++index];
102
+ continue;
103
+ }
104
+ if (tokenMatches('tour', arg)) {
105
+ if (!args[index + 1]) throw new Error(`${arg} requires a YAML file.`);
106
+ tourPath = args[++index];
107
+ continue;
108
+ }
109
+ if (arg.startsWith('-')) {
110
+ throw new Error(`Unknown present option: ${arg}`);
111
+ }
112
+ if (explicitHeadRef) {
113
+ throw new Error('present accepts at most one head ref.');
114
+ }
115
+ headRef = arg;
116
+ explicitHeadRef = arg;
117
+ }
118
+ return { headRef, baseRef, tourPath, explicitHeadRef };
119
+ }
120
+
121
+ function resolveAssetPath(packageRoot, requestPath) {
122
+ if (requestPath === '/' || requestPath === '/index.html') {
123
+ return path.join(packageRoot, 'web', 'index.html');
124
+ }
125
+ if (!requestPath.startsWith('/web/') && !requestPath.startsWith('/media/')) {
126
+ return null;
127
+ }
128
+ const candidate = path.resolve(packageRoot, `.${requestPath}`);
129
+ if (!candidate.startsWith(`${path.resolve(packageRoot)}${path.sep}`) || !existsSync(candidate)) {
130
+ return null;
131
+ }
132
+ return candidate;
133
+ }
134
+
135
+ function openBrowser(url) {
136
+ const command = process.platform === 'darwin'
137
+ ? ['open', [url]]
138
+ : process.platform === 'win32'
139
+ ? ['cmd', ['/c', 'start', '', url]]
140
+ : ['xdg-open', [url]];
141
+ const child = spawn(command[0], command[1], { detached: true, stdio: 'ignore' });
142
+ child.unref();
143
+ }
144
+
145
+ function readPort(value) {
146
+ if (!value) {
147
+ return 0;
148
+ }
149
+ const port = Number.parseInt(value, 10);
150
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
151
+ throw new Error('BYGONE_TOUR_PORT must be an integer from 0 to 65535.');
152
+ }
153
+ return port;
154
+ }
155
+
156
+ function respond(response, statusCode, message) {
157
+ if (!response.headersSent) {
158
+ response.writeHead(statusCode, { 'Content-Type': 'text/plain; charset=utf-8' });
159
+ }
160
+ response.end(message);
161
+ }
162
+
163
+ module.exports = {
164
+ parsePresentArgs,
165
+ startPresentation
166
+ };
package/cli/tour.js ADDED
@@ -0,0 +1,189 @@
1
+ const { mkdirSync, readFileSync, writeFileSync } = require('fs');
2
+ const path = require('path');
3
+ const { buildChangeTourContext } = require('../out/changeTour.js');
4
+ const { buildTourCoverageReport } = require('../out/tourCoverage.js');
5
+ const { buildManifestForTourSource, loadTourSource } = require('./tourFile.js');
6
+
7
+ const TOUR_ACTIONS = Object.freeze(['context', 'coverage', 'validate', 'compile', 'schema']);
8
+
9
+ function runTourCommand(args, cwd, packageRoot, output = process.stdout) {
10
+ const options = parseTourArgs(args);
11
+ if (options.action === 'schema') {
12
+ output.write(readFileSync(path.join(packageRoot, 'schemas', 'change-tour-source.schema.json'), 'utf8'));
13
+ return { action: 'schema' };
14
+ }
15
+ if (options.action === 'context') {
16
+ const context = buildChangeTourContext(cwd, {
17
+ headRef: options.headRef,
18
+ baseRef: options.baseRef,
19
+ maxPatchBytes: options.maxPatchBytes,
20
+ maxTotalPatchBytes: options.maxTotalPatchBytes
21
+ });
22
+ return writeJsonResult('change-tour context', context, options.outputPath, cwd, output);
23
+ }
24
+
25
+ const { resolvedPath, source } = loadTourSource(cwd, options.sourcePath);
26
+ if (options.action === 'coverage') {
27
+ const report = buildTourCoverageReport(cwd, source);
28
+ output.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderCoverageReport(report));
29
+ if (options.minimumCoverage !== undefined && report.totals.coveragePercent < options.minimumCoverage) {
30
+ throw new Error(`Tour coverage ${report.totals.coveragePercent}% is below the required ${options.minimumCoverage}%.`);
31
+ }
32
+ return { action: 'coverage', sourcePath: resolvedPath, report };
33
+ }
34
+ const manifest = buildManifestForTourSource(cwd, source);
35
+ if (options.action === 'validate') {
36
+ const result = buildValidationResult(resolvedPath, manifest);
37
+ output.write(options.json
38
+ ? `${JSON.stringify(result, null, 2)}\n`
39
+ : `Valid change tour: ${path.relative(cwd, resolvedPath) || path.basename(resolvedPath)} · ${result.walkthroughSteps} steps · ${result.summary.changedFiles} changed files · ${result.range.mergeBaseOid.slice(0, 7)} → ${result.range.headOid.slice(0, 7)}\n`);
40
+ return result;
41
+ }
42
+
43
+ const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
44
+ if (options.outputPath) {
45
+ const resolvedOutput = path.resolve(cwd, options.outputPath);
46
+ mkdirSync(path.dirname(resolvedOutput), { recursive: true });
47
+ writeFileSync(resolvedOutput, serialized, 'utf8');
48
+ output.write(`Wrote compiled change tour to ${resolvedOutput}\n`);
49
+ return { action: 'compile', outputPath: resolvedOutput, manifest };
50
+ }
51
+ output.write(serialized);
52
+ return { action: 'compile', manifest };
53
+ }
54
+
55
+ function parseTourArgs(args) {
56
+ const [action, ...rest] = args;
57
+ if (!TOUR_ACTIONS.includes(action)) {
58
+ throw new Error(`Usage: bygone tour <${TOUR_ACTIONS.join('|')}> [file.bygone.yaml]`);
59
+ }
60
+ if (action === 'schema') {
61
+ if (rest.length > 0) throw new Error('tour schema does not accept additional arguments.');
62
+ return { action };
63
+ }
64
+ if (action === 'context') return parseContextArgs(rest);
65
+ let sourcePath;
66
+ let outputPath;
67
+ let json = false;
68
+ let minimumCoverage;
69
+ for (let index = 0; index < rest.length; index += 1) {
70
+ const arg = rest[index];
71
+ if (arg === '--json') {
72
+ json = true;
73
+ continue;
74
+ }
75
+ if (arg === '--minimum-coverage') {
76
+ minimumCoverage = Number.parseInt(rest[++index], 10);
77
+ if (!Number.isInteger(minimumCoverage) || minimumCoverage < 0 || minimumCoverage > 100) {
78
+ throw new Error('--minimum-coverage requires an integer from 0 to 100.');
79
+ }
80
+ continue;
81
+ }
82
+ if (arg === '--output' || arg === '-o') {
83
+ if (!rest[index + 1]) throw new Error(`${arg} requires a file path.`);
84
+ outputPath = rest[++index];
85
+ continue;
86
+ }
87
+ if (arg.startsWith('-')) throw new Error(`Unknown tour option: ${arg}`);
88
+ if (sourcePath) throw new Error(`tour ${action} accepts exactly one source file.`);
89
+ sourcePath = arg;
90
+ }
91
+ if (!sourcePath) throw new Error(`tour ${action} requires a .bygone.yaml source file.`);
92
+ if (action === 'validate' && outputPath) throw new Error('--output is only valid with tour compile.');
93
+ if (action === 'compile' && json) throw new Error('--json is only valid with tour validate or coverage.');
94
+ if (action !== 'coverage' && minimumCoverage !== undefined) {
95
+ throw new Error('--minimum-coverage is only valid with tour coverage.');
96
+ }
97
+ return { action, sourcePath, outputPath, json, ...(action === 'coverage' ? { minimumCoverage } : {}) };
98
+ }
99
+
100
+ function renderCoverageReport(report) {
101
+ const lines = [
102
+ `Tour coverage: ${report.totals.coveredUnits}/${report.totals.includedUnits} hunks (${report.totals.coveragePercent}%)`,
103
+ `Depth: ${report.depth.mentioned} mentioned · ${report.depth.explained} explained · ${report.depth.contextualized} contextualized`
104
+ ];
105
+ if (report.totals.excludedUnits > 0) lines.push(`Excluded: ${report.totals.excludedUnits}/${report.totals.originalUnits} hunks`);
106
+ for (const file of report.files.filter((entry) => entry.uncoveredHunks.length > 0)) {
107
+ lines.push(`${file.path}: ${file.uncoveredHunks.length} uncovered (${file.uncoveredHunks.join(', ')})`);
108
+ }
109
+ for (const unsupported of report.unsupported) lines.push(`${unsupported.path}: ${unsupported.material} material not scored`);
110
+ return `${lines.join('\n')}\n`;
111
+ }
112
+
113
+ function parseContextArgs(args) {
114
+ let headRef;
115
+ let baseRef;
116
+ let outputPath;
117
+ let maxPatchBytes;
118
+ let maxTotalPatchBytes;
119
+ for (let index = 0; index < args.length; index += 1) {
120
+ const arg = args[index];
121
+ if (arg === '--base' || arg === '-m' || arg === '--main') {
122
+ if (!args[index + 1]) throw new Error(`${arg} requires a Git ref.`);
123
+ baseRef = args[++index];
124
+ continue;
125
+ }
126
+ if (arg === '--output' || arg === '-o') {
127
+ if (!args[index + 1]) throw new Error(`${arg} requires a file path.`);
128
+ outputPath = args[++index];
129
+ continue;
130
+ }
131
+ if (arg === '--max-patch-bytes') {
132
+ if (!args[index + 1]) throw new Error(`${arg} requires a positive integer.`);
133
+ maxPatchBytes = Number.parseInt(args[++index], 10);
134
+ if (!Number.isInteger(maxPatchBytes) || maxPatchBytes < 1) {
135
+ throw new Error(`${arg} requires a positive integer.`);
136
+ }
137
+ continue;
138
+ }
139
+ if (arg === '--max-total-patch-bytes') {
140
+ if (!args[index + 1]) throw new Error(`${arg} requires a positive integer.`);
141
+ maxTotalPatchBytes = Number.parseInt(args[++index], 10);
142
+ if (!Number.isInteger(maxTotalPatchBytes) || maxTotalPatchBytes < 1) {
143
+ throw new Error(`${arg} requires a positive integer.`);
144
+ }
145
+ continue;
146
+ }
147
+ if (arg.startsWith('-')) throw new Error(`Unknown tour context option: ${arg}`);
148
+ if (headRef) throw new Error('tour context accepts at most one head ref.');
149
+ headRef = arg;
150
+ }
151
+ return { action: 'context', headRef, baseRef, outputPath, maxPatchBytes, maxTotalPatchBytes };
152
+ }
153
+
154
+ function writeJsonResult(label, value, outputPath, cwd, output) {
155
+ const serialized = `${JSON.stringify(value, null, 2)}\n`;
156
+ if (!outputPath) {
157
+ output.write(serialized);
158
+ return { action: 'context', context: value };
159
+ }
160
+ const resolvedOutput = path.resolve(cwd, outputPath);
161
+ mkdirSync(path.dirname(resolvedOutput), { recursive: true });
162
+ writeFileSync(resolvedOutput, serialized, 'utf8');
163
+ output.write(`Wrote ${label} to ${resolvedOutput}\n`);
164
+ return { action: 'context', outputPath: resolvedOutput, context: value };
165
+ }
166
+
167
+ function buildValidationResult(sourcePath, manifest) {
168
+ return {
169
+ ok: true,
170
+ sourcePath,
171
+ title: manifest.title,
172
+ range: manifest.range,
173
+ summary: manifest.summary,
174
+ chapters: manifest.chapters.length,
175
+ walkthroughSteps: manifest.scenes.reduce(
176
+ (total, scene) => total + (
177
+ scene.kind === 'walkthrough' || scene.kind === 'stacked-diff' || scene.kind === 'deconstructed-diff'
178
+ ? scene.steps.length
179
+ : 0
180
+ ),
181
+ 0
182
+ )
183
+ };
184
+ }
185
+
186
+ module.exports = {
187
+ parseTourArgs,
188
+ runTourCommand
189
+ };