@davmash/bygone 0.10.0 → 0.11.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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ - Added experimental JS/TS and Python symbol evidence with declaration diffs, symbol trees, analysis diagnostics, and shared Files/Symbols navigation
6
+ - Added pinned symbol tour stops and full-file context in live presentations and portable exports
7
+ - Added the local Strand declaration matcher for JS/TS, Python, Java, Go, Rust, C#, Ruby, PHP, C, Lua, and experimental Mojo, with bounded analysis and explicit ambiguity reporting
8
+ - Kept Java and RefactoringMiner optional: exact-only generation records partial coverage, and Strand provides local inference without a JVM
9
+ - Added opt-in VS Code active-editor following with a live HEAD-to-buffer diff, Pin/Resume/Refresh controls, and safe handoffs to history and manual comparison
10
+ - Fixed following for macOS directory aliases and files at the 5,000-line limit, with regression coverage on current and minimum-supported VS Code
11
+ - Hardened correspondence artifact validation, parser budgets, and symbol owner matching
12
+ - Updated Electron, DOMPurify, fast-uri, and packaging dependencies to resolve security advisories
13
+
14
+ macOS desktop downloads are unsigned and not notarized. If Gatekeeper reports
15
+ the app as damaged, run `xattr -cr /Applications/Bygone.app`, then reopen it.
16
+
17
+ ## 0.10.2
18
+
19
+ - Prioritized matching named lines before brackets to preserve block correspondences across nesting and indentation changes
20
+ - Aligned changed indentation highlights to the start of each line while preserving highlights on edited text
21
+
22
+ ## 0.10.1
23
+
24
+ - Fixed desktop tours showing unstyled HTML after temporary asset cleanup during long-running app sessions
25
+ - Made presentation asset read failures return HTTP 500 and added HTTP and Electron ASAR regression coverage
26
+
3
27
  ## 0.10.0
4
28
 
5
29
  - Restored detailed highlighting for long prose and expanded instructions; unavailable word detail now has a uniform blue fill and an explanatory `≠` marker
package/README.md CHANGED
@@ -352,6 +352,10 @@ The wrapper scripts are:
352
352
 
353
353
  ## Commands
354
354
 
355
+ - `Bygone: Follow Active Editor`
356
+ - `Bygone: Pin Following File`
357
+ - `Bygone: Resume Following`
358
+ - `Bygone: Refresh Following Comparison`
355
359
  - `Bygone: Compare Active File With…`
356
360
  - `Bygone: Compare Selected Files`
357
361
  - `Bygone: Compare with Selected`
@@ -370,6 +374,34 @@ panes edit through VS Code documents, so changes participate in dirty state,
370
374
  undo, save, and external-document updates. Historical and non-local inputs are
371
375
  read-only. Desktop hand-offs are unavailable in remote extension hosts.
372
376
 
377
+ ## Follow the active VS Code editor
378
+
379
+ Run **Bygone: Follow Active Editor** to open one read-only comparison beside your
380
+ source editor without moving keyboard focus. Switching source files updates that
381
+ same pane. The left side is the file at HEAD; the right side is your editor buffer,
382
+ including unsaved edits. Edit, undo, and save in the source editor as usual.
383
+
384
+ **Pin** keeps the current file and baseline commit while buffer edits continue to
385
+ update. **Resume Following** selects the active or last source editor. **Refresh**
386
+ reads HEAD again when following; a pinned comparison retains its baseline. HEAD
387
+ also refreshes on file activation and when the VS Code window regains focus.
388
+ After external Git changes while the window stays focused, use Refresh.
389
+
390
+ The controls show the repository, relative file path, commit, and saved/unsaved
391
+ state. Clean files say **No changes from HEAD**. Untracked files, files absent at
392
+ the current path in HEAD, and unborn repositories use an explicitly absent
393
+ baseline. Renames are compared at the new path; following does not infer history.
394
+ **View File History** pauses following and opens the history viewer. **Manual
395
+ Compare…** pauses following and compares the source with another selected file.
396
+ Closing the pane stops following. A restored pane starts paused after reload.
397
+
398
+ Following supports trusted local Git worktrees, including linked worktrees and
399
+ files in multiple repositories. Both versions must be text, at most 128 KiB and
400
+ 5,000 lines; Git baselines must be UTF-8. Untitled, remote, virtual, notebook,
401
+ symlink, binary, and oversized files show an unavailable state. Open the source
402
+ file outside a native SCM diff to follow it. Git failures clear the old comparison
403
+ and show an error instead of treating a failed read as an empty baseline.
404
+
373
405
  ## Limitations
374
406
 
375
407
  - Bygone is intentionally diff-focused; merge tooling is not exposed as a product feature.
package/bin/bygone.js CHANGED
@@ -40,7 +40,14 @@ if (tokenMatches('completion', args[0])) {
40
40
  process.exit(0);
41
41
  }
42
42
 
43
- if (tokenMatches('tourCommand', args[0])) {
43
+ if (args[0] === 'correspondence') {
44
+ try {
45
+ require('../cli/correspondence').importCorrespondenceCommand(args.slice(1), cliCwd);
46
+ } catch (error) {
47
+ process.stderr.write(`Could not import correspondence: ${error.message}\n`);
48
+ process.exitCode = 1;
49
+ }
50
+ } else if (tokenMatches('tourCommand', args[0])) {
44
51
  try {
45
52
  runTourCommand(args.slice(1), cliCwd, packageRoot);
46
53
  } catch (error) {
@@ -13,10 +13,11 @@ const CLI_SPEC = Object.freeze({
13
13
  'bygone --read-only <desktop-arguments...>',
14
14
  'bygone review [<head>] [--base <base>]',
15
15
  'bygone -C <directory> <command-or-path> [...]',
16
- 'bygone present [<head>] [--base <base>] [--tour <file.bygone>]',
16
+ 'bygone present [<head>] [--base <base>] [--tour <file.bygone>] [--correspondence <artifact.json>]',
17
17
  'bygone tour validate <file.bygone> [--json]',
18
18
  'bygone tour compile <file.bygone> [--output <tour.json>]',
19
- 'bygone tour context [<head>] [--base <base>] [--output <context.json>]',
19
+ 'bygone tour context [<head>] [--base <base>] [--output <context.json>] [--correspondence <artifact.json>]',
20
+ 'bygone correspondence import <report.json> [<head>] [--base <base>] [--case <id>] --output <artifact.json>',
20
21
  'bygone tour coverage <file.bygone> [--json] [--minimum-coverage <0-100>]',
21
22
  'bygone tour export <file.bygone> --output <tour.html> [--profile minimal|full] [--runtime embedded|cdn]',
22
23
  'bygone <bygone://open/v1?...>',
@@ -33,6 +34,10 @@ const CLI_SPEC = Object.freeze({
33
34
  description: 'Review a committed branch against its merge base',
34
35
  argument: 'git-ref'
35
36
  },
37
+ {
38
+ id: 'correspondence', kind: 'command', tokens: ['correspondence'],
39
+ description: 'Import externally produced symbol correspondence evidence', argument: 'path'
40
+ },
36
41
  {
37
42
  id: 'completion',
38
43
  kind: 'command',
@@ -110,6 +115,10 @@ const CLI_SPEC = Object.freeze({
110
115
  description: 'Use an authored .bygone presentation',
111
116
  argument: 'path'
112
117
  },
118
+ {
119
+ id: 'correspondenceEvidence', kind: 'option', tokens: ['--correspondence'],
120
+ description: 'Use pinned symbol evidence in present or tour context', argument: 'path'
121
+ },
113
122
  {
114
123
  id: 'readOnly',
115
124
  kind: 'global',
@@ -35,7 +35,7 @@ function generateZshCompletion() {
35
35
  const reviewOptions = entriesByIds(['base', 'help'])
36
36
  .flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
37
37
  .join('\n');
38
- const presentOptions = entriesByIds(['base', 'tour', 'help'])
38
+ const presentOptions = entriesByIds(['base', 'tour', 'correspondenceEvidence', 'help'])
39
39
  .flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
40
40
  .join('\n');
41
41
  const legacyOptions = entriesByIds(['branch', 'base', 'help'])
@@ -93,10 +93,10 @@ ${historyOptions}
93
93
  elif [[ "$words[3]" == "context" ]]; then
94
94
  if [[ "$previous" == ${shellAlternation(tokensFor('base'))} ]]; then
95
95
  _bygone_git_refs
96
- elif [[ "$previous" == "--output" || "$previous" == "-o" ]]; then
96
+ elif [[ "$previous" == "--output" || "$previous" == "-o" || "$previous" == "--correspondence" ]]; then
97
97
  _files
98
98
  else
99
- compadd -- --base --output -o --max-patch-bytes --max-total-patch-bytes
99
+ compadd -- --base --output -o --max-patch-bytes --max-total-patch-bytes --correspondence
100
100
  _bygone_git_refs
101
101
  fi
102
102
  elif [[ "$words[3]" == "coverage" || "$words[3]" == "validate" || "$words[3]" == "compile" ]]; then
@@ -112,7 +112,7 @@ ${historyOptions}
112
112
  fi
113
113
  ;;
114
114
  present)
115
- if [[ "$previous" == ${shellAlternation(tokensFor('tour'))} ]]; then
115
+ if [[ "$previous" == ${shellAlternation(tokensFor('tour'))} || "$previous" == "--correspondence" ]]; then
116
116
  _files -g '*.bygone' -g '*.bygone.yaml'
117
117
  elif [[ "$previous" == ${shellAlternation(tokensFor('base'))} ]]; then
118
118
  _bygone_git_refs
@@ -152,7 +152,7 @@ compdef _bygone bygone
152
152
  function generateBashCompletion() {
153
153
  const rootTokens = completionEntries().flatMap((entry) => entry.tokens).join(' ');
154
154
  const reviewTokens = entriesByIds(['base', 'help']).flatMap((entry) => entry.tokens).join(' ');
155
- const presentTokens = entriesByIds(['base', 'tour', 'help']).flatMap((entry) => entry.tokens).join(' ');
155
+ const presentTokens = entriesByIds(['base', 'tour', 'correspondenceEvidence', 'help']).flatMap((entry) => entry.tokens).join(' ');
156
156
  const legacyTokens = entriesByIds(['branch', 'base', 'help']).flatMap((entry) => entry.tokens).join(' ');
157
157
  const historyTokens = entriesByIds(['includeStaged', 'help']).flatMap((entry) => entry.tokens).join(' ');
158
158
  const baseTokens = tokensFor('base').join('|');
@@ -188,11 +188,11 @@ _bygone() {
188
188
  refs="$(_bygone_git_refs)"
189
189
  if [[ "$prev" =~ ^(${baseTokens})$ ]]; then
190
190
  COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
191
- elif [[ "$prev" == "--output" || "$prev" == "-o" ]]; then
191
+ elif [[ "$prev" == "--output" || "$prev" == "-o" || "$prev" == "--correspondence" ]]; then
192
192
  COMPREPLY=( $(compgen -f -- "$cur") )
193
193
  compopt -o filenames 2>/dev/null || true
194
194
  else
195
- COMPREPLY=( $(compgen -W '--base --output -o --max-patch-bytes --max-total-patch-bytes '"$refs" -- "$cur") )
195
+ COMPREPLY=( $(compgen -W '--base --output -o --max-patch-bytes --max-total-patch-bytes --correspondence '"$refs" -- "$cur") )
196
196
  fi
197
197
  else
198
198
  COMPREPLY=( $(compgen -W '--json --output -o' -- "$cur") $(compgen -f -- "$cur") )
@@ -209,7 +209,7 @@ _bygone() {
209
209
  ;;
210
210
  present)
211
211
  refs="$(_bygone_git_refs)"
212
- if [[ "$prev" == "--tour" ]]; then
212
+ if [[ "$prev" == "--tour" || "$prev" == "--correspondence" ]]; then
213
213
  COMPREPLY=( $(compgen -f -- "$cur") )
214
214
  compopt -o filenames 2>/dev/null || true
215
215
  elif [[ "$prev" =~ ^(${baseTokens})$ ]]; then
@@ -274,6 +274,7 @@ function generateFishCompletion() {
274
274
  "complete -c bygone -n '__fish_seen_subcommand_from review' -a '(__bygone_git_refs)' -d 'Git ref'",
275
275
  `complete -c bygone -n '__fish_seen_subcommand_from present' ${fishTokens(tokensFor('base'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('base').description)}'`,
276
276
  `complete -c bygone -n '__fish_seen_subcommand_from present' ${fishTokens(tokensFor('tour'))} -r -d '${escapeSingleQuoted(getCliEntry('tour').description)}'`,
277
+ `complete -c bygone ${fishTokens(tokensFor('correspondenceEvidence'))} -r -d 'Use pinned symbol correspondence evidence'`,
277
278
  "complete -c bygone -n '__fish_seen_subcommand_from present' -a '(__bygone_git_refs)' -d 'Git ref'",
278
279
  `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
280
  `complete -c bygone -n 'string match -q "*--branch-diff*" -- (commandline -opc)' ${fishTokens(tokensFor('base'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('base').description)}'`,
@@ -0,0 +1,71 @@
1
+ const path = require('path');
2
+ const { lstatSync, mkdirSync, statSync, writeFileSync } = require('fs');
3
+ const { resolveBranchReviewRange } = require('../out/gitComparison');
4
+ const { importChangeCorrespondence, readCorrespondenceJson } = require('../out/changeCorrespondence');
5
+
6
+ function importCorrespondenceCommand(args, cwd, output = process.stdout) {
7
+ if (args[0] !== 'import') throw new Error('Usage: bygone correspondence import <report.json> [<head>] --base <base> [--case <id>] --output <artifact.json>');
8
+ const options = {};
9
+ const positional = [];
10
+ for (let i = 1; i < args.length; i++) {
11
+ const arg = args[i];
12
+ if (['--base', '--case', '--output', '-o'].includes(arg)) {
13
+ if (!args[i + 1] || args[i + 1].startsWith('-')) throw new Error(`${arg} requires a value.`);
14
+ options[arg === '-o' ? '--output' : arg] = args[++i];
15
+ } else if (arg.startsWith('-')) throw new Error(`Unknown correspondence option: ${arg}`);
16
+ else positional.push(arg);
17
+ }
18
+ if (!positional.length || positional.length > 2 || !options['--output']) throw new Error('Provide a producer report, at most one head ref, and --output.');
19
+ const reportPath = path.resolve(cwd, positional[0]);
20
+ const destination = path.resolve(cwd, options['--output']);
21
+ assertDestinationAvailable(reportPath, destination);
22
+ const report = readCorrespondenceJson(reportPath);
23
+ if (!report || typeof report !== 'object') throw new Error('Invalid correspondence report.');
24
+ let selected = report;
25
+ if (Array.isArray(report.cases)) {
26
+ if (!options['--case']) throw new Error('A multi-case producer report requires --case.');
27
+ const matches = report.cases.filter(c => c.id === options['--case']);
28
+ if (matches.length !== 1) throw new Error('Select exactly one existing producer case.');
29
+ selected = matches[0];
30
+ } else if (options['--case']) throw new Error('--case requires a multi-case producer report.');
31
+ const range = resolveBranchReviewRange(cwd, positional[1] || 'HEAD', options['--base']);
32
+ const artifact = importChangeCorrespondence(range.repoRoot, selected, { baseOid: range.mergeBaseOid, headOid: range.headOid });
33
+ mkdirSync(path.dirname(destination), { recursive: true });
34
+ try {
35
+ writeFileSync(destination, JSON.stringify(artifact, null, 2) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 });
36
+ } catch (error) {
37
+ if (error?.code === 'EEXIST') throw new Error(`The correspondence output already exists at ${destination}; choose a new path.`, { cause: error });
38
+ throw new Error(`Could not create correspondence output at ${destination}: ${error.message}`, { cause: error });
39
+ }
40
+ output.write(`Wrote ${artifact.relations.length} inferred correspondences (${artifact.status}) to ${destination}\n`);
41
+ return artifact;
42
+ }
43
+
44
+ function assertDestinationAvailable(reportPath, destination) {
45
+ if (sameActualFile(reportPath, destination)) {
46
+ throw new Error('The imported artifact must not overwrite the producer report or any of its symlink/hardlink aliases.');
47
+ }
48
+ try {
49
+ lstatSync(destination);
50
+ } catch (error) {
51
+ if (error?.code === 'ENOENT') return;
52
+ throw new Error(`Could not inspect correspondence output at ${destination}: ${error.message}`, { cause: error });
53
+ }
54
+ throw new Error(`The correspondence output already exists at ${destination}; choose a new path.`);
55
+ }
56
+
57
+ function sameActualFile(first, second) {
58
+ if (first === second) return true;
59
+ try {
60
+ const left = statSync(first);
61
+ const right = statSync(second);
62
+ if (left.dev !== undefined && left.ino !== undefined
63
+ && left.dev === right.dev && left.ino === right.ino
64
+ && left.ino !== 0 && right.ino !== 0) return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ module.exports = { importCorrespondenceCommand };
package/cli/present.js CHANGED
@@ -1,8 +1,9 @@
1
- const { createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } = require('fs');
1
+ const { existsSync, mkdirSync, readFile, readFileSync, realpathSync, statSync, writeFileSync } = require('fs');
2
2
  const { createServer } = require('http');
3
3
  const path = require('path');
4
4
  const { spawn } = require('child_process');
5
5
  const { buildChangeTourManifest, parseChangeTourStory } = require('../out/changeTour.js');
6
+ const { loadChangeCorrespondence } = require('../out/changeCorrespondence');
6
7
  const { buildTourAuthoringCoverage, buildTourCoverageReport } = require('../out/tourCoverage.js');
7
8
  const { buildTourWindowTitle } = require('../out/windowTitle.js');
8
9
  const { tokenMatches } = require('./commandSpec.js');
@@ -22,7 +23,7 @@ const MIME_TYPES = new Map([
22
23
  ]);
23
24
 
24
25
  async function startPresentation(args, cwd, packageRoot, options = {}) {
25
- const { headRef, baseRef, tourPath, explicitHeadRef } = parsePresentArgs(args);
26
+ const { headRef, baseRef, tourPath, explicitHeadRef, correspondencePath } = parsePresentArgs(args);
26
27
  const story = !options.ignoreEnvironment && process.env.BYGONE_TOUR_STORY
27
28
  ? parseChangeTourStory(JSON.parse(readFileSync(path.resolve(cwd, process.env.BYGONE_TOUR_STORY), 'utf8')))
28
29
  : undefined;
@@ -48,6 +49,21 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
48
49
  const contained = relative && relative !== '..' && !relative.startsWith('../') && !path.isAbsolute(relative);
49
50
  manifest.localSource = { repo: pathToFileURL(root).href, tour: contained ? relative : pathToFileURL(documentPath).href };
50
51
  }
52
+ const repositoryRoot = builtManifest.repository?.root || cwd;
53
+ const authoredCorrespondencePath = source?.correspondence
54
+ ? path.resolve(repositoryRoot, source.correspondence)
55
+ : undefined;
56
+ const explicitCorrespondencePath = correspondencePath
57
+ ? path.resolve(cwd, correspondencePath)
58
+ : undefined;
59
+ if (authoredCorrespondencePath && explicitCorrespondencePath
60
+ && !sameActualFile(authoredCorrespondencePath, explicitCorrespondencePath)) {
61
+ throw new Error(`Conflicting correspondence artifacts: source.correspondence (${authoredCorrespondencePath}) and --correspondence (${explicitCorrespondencePath}) must identify the same actual file.`);
62
+ }
63
+ const correspondencePathToLoad = explicitCorrespondencePath || authoredCorrespondencePath;
64
+ const correspondence = correspondencePathToLoad
65
+ ? loadChangeCorrespondence(repositoryRoot, correspondencePathToLoad, { baseOid: manifest.range.mergeBaseOid, headOid: manifest.range.headOid })
66
+ : null;
51
67
  const history = createTourHistory(manifest);
52
68
  const serializedManifest = `${JSON.stringify(manifest, null, 2)}\n`;
53
69
  const outputPath = options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_OUTPUT;
@@ -60,13 +76,16 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
60
76
 
61
77
  const tourWindowTitle = buildTourWindowTitle(manifest, 'Bygone');
62
78
  const presenterIndexPath = path.join(packageRoot, 'web', 'index.html');
63
- let presenterIndexTemplate;
64
79
  // Uploaded documents remain immutable, scoped to this existing repository,
65
80
  // and addressed independently so opening one cannot mutate another tab.
66
81
  const uploadedTours = new Map();
67
82
 
68
83
  const server = createServer((request, response) => {
69
84
  const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
85
+ if (requestUrl.pathname === '/correspondence.json') {
86
+ if (request.method !== 'GET') return respondJson(response, 405, { error: 'Method not allowed' });
87
+ return respondJson(response, correspondence ? 200 : 404, correspondence || { error: 'No correspondence supplied' });
88
+ }
70
89
  if (requestUrl.pathname === '/tour/open') {
71
90
  if (request.method !== 'POST') return respondJson(response, 405, { error: 'Method not allowed' });
72
91
  if (!isSameOriginLoopbackRequest(request)) return respondJson(response, 403, { error: 'Forbidden' });
@@ -159,18 +178,20 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
159
178
  return;
160
179
  }
161
180
  if (requestUrl.pathname === '/' || requestUrl.pathname === '/index.html') {
162
- if (!presenterIndexTemplate) {
163
- presenterIndexTemplate = readFileSync(presenterIndexPath, 'utf8');
164
- }
165
- const html = presenterIndexTemplate.replace(
166
- /<title>Bygone Tour<\/title>/,
167
- `<title>${escapeHtml(tourWindowTitle)}</title>`
168
- );
169
- response.writeHead(200, {
170
- 'Content-Type': 'text/html; charset=utf-8',
171
- 'Cache-Control': 'no-store'
181
+ // Read HTML like the runtime assets so a rebuild cannot leave a
182
+ // running presentation serving old controls with new JavaScript.
183
+ readFile(presenterIndexPath, 'utf8', (error, template) => {
184
+ if (error) { respond(response, 500, 'Read failed'); return; }
185
+ const html = template.replace(
186
+ /<title>Bygone Tour<\/title>/,
187
+ `<title>${escapeHtml(tourWindowTitle)}</title>`
188
+ );
189
+ response.writeHead(200, {
190
+ 'Content-Type': 'text/html; charset=utf-8',
191
+ 'Cache-Control': 'no-store'
192
+ });
193
+ response.end(html);
172
194
  });
173
- response.end(html);
174
195
  return;
175
196
  }
176
197
  const targetPath = resolveAssetPath(packageRoot, requestUrl.pathname);
@@ -178,11 +199,20 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
178
199
  respond(response, 404, 'Not found');
179
200
  return;
180
201
  }
181
- response.writeHead(200, {
182
- 'Content-Type': MIME_TYPES.get(path.extname(targetPath)) || 'application/octet-stream',
183
- 'Cache-Control': 'no-store'
202
+ // Electron streams extract ASAR entries to cached temporary files. Those
203
+ // can disappear during a long-running session; readFile reads the archive
204
+ // directly and lets us report failures before committing success headers.
205
+ readFile(targetPath, (error, content) => {
206
+ if (error) {
207
+ respond(response, 500, 'Read failed');
208
+ return;
209
+ }
210
+ response.writeHead(200, {
211
+ 'Content-Type': MIME_TYPES.get(path.extname(targetPath)) || 'application/octet-stream',
212
+ 'Cache-Control': 'no-store'
213
+ });
214
+ response.end(content);
184
215
  });
185
- createReadStream(targetPath).on('error', () => respond(response, 500, 'Read failed')).pipe(response);
186
216
  });
187
217
  const requestedPort = readPort(options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_PORT);
188
218
  await new Promise((resolve, reject) => {
@@ -194,7 +224,7 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
194
224
  server.close();
195
225
  throw new Error('Could not determine the presentation server address.');
196
226
  }
197
- const url = `http://127.0.0.1:${address.port}/?manifest=/tour.json${options.location ? serializeDocumentFragment(options.location.mode, options.location.focus) : ''}`;
227
+ const url = `http://127.0.0.1:${address.port}/?manifest=/tour.json${correspondence ? '&correspondence=1' : ''}${options.location ? serializeDocumentFragment(options.location.mode, options.location.focus) : ''}`;
198
228
  if (options.announce !== false) {
199
229
  process.stdout.write(`Bygone change tour running at ${url}\n`);
200
230
  process.stdout.write('Press Ctrl+C to stop.\n');
@@ -216,6 +246,7 @@ function parsePresentArgs(args) {
216
246
  let explicitHeadRef;
217
247
  let baseRef;
218
248
  let tourPath;
249
+ let correspondencePath;
219
250
  for (let index = 0; index < args.length; index += 1) {
220
251
  const arg = args[index];
221
252
  if (tokenMatches('base', arg)) {
@@ -225,6 +256,11 @@ function parsePresentArgs(args) {
225
256
  baseRef = args[++index];
226
257
  continue;
227
258
  }
259
+ if (arg === '--correspondence') {
260
+ if (!args[index + 1] || args[index + 1].startsWith('-')) throw new Error('--correspondence requires a file path.');
261
+ correspondencePath = args[++index];
262
+ continue;
263
+ }
228
264
  if (tokenMatches('tour', arg)) {
229
265
  if (!args[index + 1]) throw new Error(`${arg} requires a YAML file.`);
230
266
  tourPath = args[++index];
@@ -239,7 +275,7 @@ function parsePresentArgs(args) {
239
275
  headRef = arg;
240
276
  explicitHeadRef = arg;
241
277
  }
242
- return { headRef, baseRef, tourPath, explicitHeadRef };
278
+ return { headRef, baseRef, tourPath, explicitHeadRef, ...(correspondencePath ? { correspondencePath } : {}) };
243
279
  }
244
280
 
245
281
  function resolveAssetPath(packageRoot, requestPath) {
@@ -306,6 +342,24 @@ function isSameOriginLoopbackRequest(request) {
306
342
  }
307
343
  }
308
344
 
345
+ function sameActualFile(first, second) {
346
+ if (first === second) return true;
347
+ try {
348
+ const left = statSync(first);
349
+ const right = statSync(second);
350
+ if (left.dev !== undefined && left.ino !== undefined
351
+ && left.dev === right.dev && left.ino === right.ino
352
+ && left.ino !== 0 && right.ino !== 0) return true;
353
+ } catch {
354
+ // Fall back to canonical paths on filesystems without usable identity metadata.
355
+ }
356
+ try {
357
+ return realpathSync(first) === realpathSync(second);
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+
309
363
  function escapeHtml(text) {
310
364
  return text
311
365
  .replaceAll('&', '&amp;')
package/cli/tour.js CHANGED
@@ -37,6 +37,7 @@ function runTourCommand(args, cwd, packageRoot, output = process.stdout) {
37
37
  const context = buildChangeTourContext(cwd, {
38
38
  headRef: options.headRef,
39
39
  baseRef: options.baseRef,
40
+ correspondencePath: options.correspondencePath ? path.resolve(cwd, options.correspondencePath) : undefined,
40
41
  maxPatchBytes: options.maxPatchBytes,
41
42
  maxTotalPatchBytes: options.maxTotalPatchBytes
42
43
  });
@@ -147,6 +148,7 @@ function parseContextArgs(args) {
147
148
  let baseRef;
148
149
  let outputPath;
149
150
  let maxPatchBytes;
151
+ let correspondencePath;
150
152
  let maxTotalPatchBytes;
151
153
  for (let index = 0; index < args.length; index += 1) {
152
154
  const arg = args[index];
@@ -160,6 +162,11 @@ function parseContextArgs(args) {
160
162
  outputPath = args[++index];
161
163
  continue;
162
164
  }
165
+ if (arg === '--correspondence') {
166
+ if (!args[index + 1] || args[index + 1].startsWith('-')) throw new Error('--correspondence requires a file path.');
167
+ correspondencePath = args[++index];
168
+ continue;
169
+ }
163
170
  if (arg === '--max-patch-bytes') {
164
171
  if (!args[index + 1]) throw new Error(`${arg} requires a positive integer.`);
165
172
  maxPatchBytes = Number.parseInt(args[++index], 10);
@@ -180,7 +187,7 @@ function parseContextArgs(args) {
180
187
  if (headRef) throw new Error('tour context accepts at most one head ref.');
181
188
  headRef = arg;
182
189
  }
183
- return { action: 'context', headRef, baseRef, outputPath, maxPatchBytes, maxTotalPatchBytes };
190
+ return { action: 'context', headRef, baseRef, outputPath, maxPatchBytes, maxTotalPatchBytes, ...(correspondencePath ? { correspondencePath } : {}) };
184
191
  }
185
192
 
186
193
  function writeJsonResult(label, value, outputPath, cwd, output) {
package/cli/tourExport.js CHANGED
@@ -78,7 +78,7 @@ function materializeTour(sourcePath, profile) {
78
78
  delete manifest.localSource;
79
79
  // A source URL is optional provenance and may carry credentials or local bindings.
80
80
  delete manifest.sourceUrl;
81
- const contentKeys = new Set(['leftContent', 'rightContent', 'content']);
81
+ const contentKeys = new Set(['leftContent', 'rightContent', 'content', 'text']);
82
82
  function pack(value) {
83
83
  if (Array.isArray(value)) return value.map(pack);
84
84
  if (!value || typeof value !== 'object') return value;
@@ -14,6 +14,7 @@ _bygone() {
14
14
  local -a root_items review_options present_options legacy_options history_options shells tour_actions
15
15
  root_items=(
16
16
  'review:Review a committed branch against its merge base'
17
+ 'correspondence:Import externally produced symbol correspondence evidence'
17
18
  'completion:Print shell completion source'
18
19
  'present:Open an app-hosted tour of a committed branch range'
19
20
  'tour:Validate, compile, or inspect authored change tours'
@@ -41,6 +42,7 @@ _bygone() {
41
42
  '-m:Set the branch-review base ref'
42
43
  '--main:Set the branch-review base ref'
43
44
  '--tour:Use an authored .bygone presentation'
45
+ '--correspondence:Use pinned symbol evidence in present or tour context'
44
46
  '-h:Show command help'
45
47
  '--help:Show command help'
46
48
  )
@@ -78,10 +80,10 @@ _bygone() {
78
80
  elif [[ "$words[3]" == "context" ]]; then
79
81
  if [[ "$previous" == (--base|-m|--main) ]]; then
80
82
  _bygone_git_refs
81
- elif [[ "$previous" == "--output" || "$previous" == "-o" ]]; then
83
+ elif [[ "$previous" == "--output" || "$previous" == "-o" || "$previous" == "--correspondence" ]]; then
82
84
  _files
83
85
  else
84
- compadd -- --base --output -o --max-patch-bytes --max-total-patch-bytes
86
+ compadd -- --base --output -o --max-patch-bytes --max-total-patch-bytes --correspondence
85
87
  _bygone_git_refs
86
88
  fi
87
89
  elif [[ "$words[3]" == "coverage" || "$words[3]" == "validate" || "$words[3]" == "compile" ]]; then
@@ -97,7 +99,7 @@ _bygone() {
97
99
  fi
98
100
  ;;
99
101
  present)
100
- if [[ "$previous" == (--tour) ]]; then
102
+ if [[ "$previous" == (--tour) || "$previous" == "--correspondence" ]]; then
101
103
  _files -g '*.bygone' -g '*.bygone.yaml'
102
104
  elif [[ "$previous" == (--base|-m|--main) ]]; then
103
105
  _bygone_git_refs
@@ -12,7 +12,7 @@ _bygone() {
12
12
  first="${COMP_WORDS[1]}"
13
13
 
14
14
  if (( COMP_CWORD == 1 )); then
15
- COMPREPLY=( $(compgen -W 'review completion present tour --diff --history --git-diff --branch-diff --test --read-only -C -h --help -v --version' -- "$cur") $(compgen -f -- "$cur") )
15
+ COMPREPLY=( $(compgen -W 'review correspondence completion present tour --diff --history --git-diff --branch-diff --test --read-only -C -h --help -v --version' -- "$cur") $(compgen -f -- "$cur") )
16
16
  compopt -o filenames 2>/dev/null || true
17
17
  return
18
18
  fi
@@ -28,11 +28,11 @@ _bygone() {
28
28
  refs="$(_bygone_git_refs)"
29
29
  if [[ "$prev" =~ ^(--base|-m|--main)$ ]]; then
30
30
  COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
31
- elif [[ "$prev" == "--output" || "$prev" == "-o" ]]; then
31
+ elif [[ "$prev" == "--output" || "$prev" == "-o" || "$prev" == "--correspondence" ]]; then
32
32
  COMPREPLY=( $(compgen -f -- "$cur") )
33
33
  compopt -o filenames 2>/dev/null || true
34
34
  else
35
- COMPREPLY=( $(compgen -W '--base --output -o --max-patch-bytes --max-total-patch-bytes '"$refs" -- "$cur") )
35
+ COMPREPLY=( $(compgen -W '--base --output -o --max-patch-bytes --max-total-patch-bytes --correspondence '"$refs" -- "$cur") )
36
36
  fi
37
37
  else
38
38
  COMPREPLY=( $(compgen -W '--json --output -o' -- "$cur") $(compgen -f -- "$cur") )
@@ -49,13 +49,13 @@ _bygone() {
49
49
  ;;
50
50
  present)
51
51
  refs="$(_bygone_git_refs)"
52
- if [[ "$prev" == "--tour" ]]; then
52
+ if [[ "$prev" == "--tour" || "$prev" == "--correspondence" ]]; then
53
53
  COMPREPLY=( $(compgen -f -- "$cur") )
54
54
  compopt -o filenames 2>/dev/null || true
55
55
  elif [[ "$prev" =~ ^(--base|-m|--main)$ ]]; then
56
56
  COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
57
57
  else
58
- COMPREPLY=( $(compgen -W '--base -m --main --tour -h --help '"$refs" -- "$cur") )
58
+ COMPREPLY=( $(compgen -W '--base -m --main --tour --correspondence -h --help '"$refs" -- "$cur") )
59
59
  fi
60
60
  ;;
61
61
  --branch-diff)
@@ -5,6 +5,7 @@ function __bygone_git_refs
5
5
  end
6
6
 
7
7
  complete -c bygone -n '__fish_use_subcommand' -a review -d 'Review a committed branch against its merge base'
8
+ complete -c bygone -n '__fish_use_subcommand' -a correspondence -r -d 'Import externally produced symbol correspondence evidence'
8
9
  complete -c bygone -n '__fish_use_subcommand' -a completion -d 'Print shell completion source'
9
10
  complete -c bygone -n '__fish_use_subcommand' -a present -d 'Open an app-hosted tour of a committed branch range'
10
11
  complete -c bygone -n '__fish_use_subcommand' -a tour -d 'Validate, compile, or inspect authored change tours'
@@ -30,6 +31,7 @@ complete -c bygone -n '__fish_seen_subcommand_from review' -l base -s m -l main
30
31
  complete -c bygone -n '__fish_seen_subcommand_from review' -a '(__bygone_git_refs)' -d 'Git ref'
31
32
  complete -c bygone -n '__fish_seen_subcommand_from present' -l base -s m -l main -r -a '(__bygone_git_refs)' -d 'Set the branch-review base ref'
32
33
  complete -c bygone -n '__fish_seen_subcommand_from present' -l tour -r -d 'Use an authored .bygone presentation'
34
+ complete -c bygone -l correspondence -r -d 'Use pinned symbol correspondence evidence'
33
35
  complete -c bygone -n '__fish_seen_subcommand_from present' -a '(__bygone_git_refs)' -d 'Git ref'
34
36
  complete -c bygone -n 'string match -q "*--branch-diff*" -- (commandline -opc)' -s b -l branch -r -a '(__bygone_git_refs)' -d 'Set the branch-review head ref'
35
37
  complete -c bygone -n 'string match -q "*--branch-diff*" -- (commandline -opc)' -l base -s m -l main -r -a '(__bygone_git_refs)' -d 'Set the branch-review base ref'