@feng0531/dsh-vcs 0.4.9 → 0.5.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.
package/local-command.mjs CHANGED
@@ -1,46 +1,79 @@
1
1
  import {spawn} from 'node:child_process';
2
+ import path from 'node:path';
3
+ import {currentOperationBudget} from './src/operation-budget.mjs';
2
4
 
3
5
  const MAX_OUTPUT = 16 * 1024 * 1024;
4
6
  export const TIMEOUT = 15000;
7
+ const KILL_GRACE_MS = 100;
5
8
 
6
9
  // Local adapter primitive, not the remote SVN transport. Preserve its error contract.
7
- export function run(command, args, cwd, max = MAX_OUTPUT, { signal, timeoutMs = TIMEOUT } = {}) {
10
+ export function run(command, args, cwd, max = MAX_OUTPUT, {signal, timeoutMs = TIMEOUT} = {}) {
8
11
  return new Promise((resolve, reject) => {
9
- if (signal?.aborted) { reject(Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' })); return; }
12
+ const budget = currentOperationBudget();
13
+ if (signal?.aborted) { reject(Object.assign(new Error('Discovery cancelled'), {code: 'ABORT_ERR'})); return; }
14
+ try {
15
+ budget?.throwIfExpired();
16
+ if (!Number.isInteger(max) || max < 1 || max > MAX_OUTPUT || !Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('Invalid local command bounds.');
17
+ timeoutMs = Math.min(timeoutMs, TIMEOUT, budget?.remaining() ?? TIMEOUT);
18
+ } catch (error) { reject(error); return; }
10
19
  // Inherited Git overrides must not redirect reads to a different repository.
11
20
  const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
12
- Object.assign(env, { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1', GIT_ALLOW_PROTOCOL: '', LC_ALL: process.platform === 'linux' ? 'C.UTF-8' : 'en_US.UTF-8' });
13
- const child = spawn(command, args, { cwd, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env });
21
+ Object.assign(env, {GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1', GIT_ALLOW_PROTOCOL: '', LC_ALL: process.platform === 'linux' ? 'C.UTF-8' : 'en_US.UTF-8'});
22
+ const child = spawn(command, args, {cwd, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], env});
14
23
  const out = [], err = [];
15
- let size = 0, error, done = false;
24
+ let size = 0, error, done = false, closed = false, exitCode, stopping = false, cleanupDone = true, killTimer;
25
+ const signals = [...new Set([signal, budget?.signal].filter(Boolean))];
26
+ const finish = () => {
27
+ if (done || !closed || !cleanupDone) return;
28
+ done = true;
29
+ clearTimeout(timer);
30
+ clearTimeout(killTimer);
31
+ for (const source of signals) source.removeEventListener('abort', abort);
32
+ if (error) reject(error);
33
+ else if (exitCode !== 0) reject(Object.assign(new Error(command + ': ' + Buffer.concat(err).toString('utf8').trim()), {code: 'VCS_COMMAND', exitCode}));
34
+ else resolve(Buffer.concat(out));
35
+ };
36
+ const killGroup = strength => {
37
+ try { process.kill(-child.pid, strength); } catch (failure) {
38
+ if (failure.code !== 'ESRCH') { try { child.kill(strength); } catch {} }
39
+ }
40
+ };
16
41
  const stop = failure => {
17
42
  error ??= failure;
18
- child.kill();
43
+ if (stopping || !child.pid) return;
44
+ stopping = true; cleanupDone = false;
45
+ if (process.platform === 'win32') {
46
+ // Kill the tree before its parent can disappear and orphan pipe-owning descendants.
47
+ const executable = path.join(process.env.SystemRoot || 'C:/Windows', 'System32', 'taskkill.exe');
48
+ const killer = spawn(executable, ['/PID', String(child.pid), '/T', '/F'], {windowsHide: true, shell: false, stdio: 'ignore'});
49
+ killer.on('error', () => { try { child.kill('SIGKILL'); } catch {} });
50
+ const guard = setTimeout(() => { try { child.kill('SIGKILL'); killer.kill(); } catch {} }, 1000);
51
+ killer.once('close', () => { clearTimeout(guard); cleanupDone = true; finish(); });
52
+ } else {
53
+ killGroup('SIGTERM');
54
+ killTimer = setTimeout(() => { killGroup('SIGKILL'); cleanupDone = true; finish(); }, KILL_GRACE_MS);
55
+ }
19
56
  };
20
- const abort = () => stop(Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' }));
21
- signal?.addEventListener('abort', abort, { once: true });
22
- const timer = setTimeout(() => stop(Object.assign(new Error(command + ' timed out'), { code: 'TIMEOUT' })), timeoutMs);
23
- // Both streams consume the same budget; never expose partial success.
57
+ const abort = () => {
58
+ const source = signals.find(value => value.aborted);
59
+ const reason = source?.reason?.code === 'TIMEOUT' ? source.reason : Object.assign(new Error('Discovery cancelled'), {code: 'ABORT_ERR'});
60
+ stop(reason);
61
+ };
62
+ const timer = setTimeout(() => stop(Object.assign(new Error(command + ' timed out'), {code: 'TIMEOUT'})), timeoutMs);
63
+ // Both streams consume the same budget; never retain unbounded or partial output.
24
64
  const collect = chunks => bytes => {
65
+ if (error) return;
25
66
  size += bytes.length;
26
- if (size > max) {
27
- stop(Object.assign(new Error('Output exceeds size limit'), {code: 'TOO_LARGE'}));
28
- } else {
29
- chunks.push(bytes);
30
- }
67
+ if (size > max) stop(Object.assign(new Error('Output exceeds size limit'), {code: 'TOO_LARGE'}));
68
+ else chunks.push(bytes);
31
69
  };
32
70
  child.stdout.on('data', collect(out));
33
71
  child.stderr.on('data', collect(err));
34
- const finish = failure => {
35
- if (done) return;
36
- done = true;
37
- clearTimeout(timer);
38
- signal?.removeEventListener('abort', abort);
39
- if (failure) reject(failure);
40
- else resolve(Buffer.concat(out));
41
- };
42
- child.on('error', finish);
43
- child.on('close', code => finish(error || (code !== 0 ? Object.assign(new Error(command + ': ' + Buffer.concat(err).toString('utf8').trim()), { code: 'VCS_COMMAND', exitCode: code }) : null)));
72
+ child.on('error', failure => { error ??= failure; });
73
+ child.once('close', code => { closed = true; exitCode = code; finish(); });
74
+ for (const source of signals) source.addEventListener('abort', abort, {once: true});
75
+ // Abort can race spawn/listener registration.
76
+ if (signals.some(value => value.aborted)) abort();
44
77
  });
45
78
  }
46
79
  export const git = (root, args, max, options) => run('git', ['--no-pager', '--literal-pathspecs', '-c', 'core.fsmonitor=false', ...args], root, max, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-vcs",
3
- "version": "0.4.9",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
6
  "type": "module",
@@ -27,6 +27,9 @@
27
27
  "src/svn-path.mjs",
28
28
  "src/svn-revision.mjs",
29
29
  "src/commit-signature.mjs",
30
+ "src/operation-budget.mjs",
31
+ "src/historical-access.mjs",
32
+ "src/preview-capabilities.mjs",
30
33
  "dist",
31
34
  "locale",
32
35
  "icon.svg",
@@ -43,10 +46,15 @@
43
46
  "prepare": "npm run build",
44
47
  "prepack": "npm run check",
45
48
  "verify:package": "node scripts/verify-package.mjs",
46
- "test:install": "node scripts/test-install.mjs"
49
+ "test:install": "node scripts/test-install.mjs",
50
+ "test:host-compat": "node scripts/test-host-compat.mjs",
51
+ "test:release": "node scripts/run-release-tests.mjs",
52
+ "test:preflight": "node scripts/test-preflight.mjs",
53
+ "test:browser": "node scripts/monaco-fixture.mjs && node scripts/run-isolated-browser.mjs",
54
+ "test:performance": "node scripts/performance-baseline.mjs"
47
55
  },
48
56
  "peerDependencies": {
49
- "@deepseek-ai/dsh": "0.1.7-rc.2 || 0.2.0-rc.2"
57
+ "@deepseek-ai/dsh": "0.1.7-rc.2 || 0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5"
50
58
  },
51
59
  "peerDependenciesMeta": {
52
60
  "@deepseek-ai/dsh": { "optional": true }
@@ -1,15 +1,28 @@
1
- // Bounded commit-signature presence detection from the raw commit-object header.
2
- // Read-only by design: only inspects the header for gpgsig / gpgsig-sha256 / sshsig
3
- // markers. It never runs gpg/ssh-keygen verification, never touches the network, and
4
- // never writes. A result of {signed:true} means "a signature header is present", not
5
- // "trust-verified".
1
+ // Signature presence only: Git stores SSH and OpenPGP armor in gpgsig or
2
+ // gpgsig-sha256 multiline headers. The suffix names the object hash, not the
3
+ // signature algorithm. This never invokes a verifier or implies trust.
6
4
  export function parseCommitSignature(raw) {
7
- const none = { signed: false, format: null };
5
+ const none = {signed:false, format:null};
8
6
  if (typeof raw !== 'string') return none;
9
- const boundary = raw.indexOf('\n\n');
10
- const header = boundary === -1 ? raw : raw.slice(0, boundary);
11
- if (/^sshsig /m.test(header)) return { signed: true, format: 'SSH' };
12
- if (/^gpgsig-sha256 /m.test(header)) return { signed: true, format: 'OpenPGP-SHA256' };
13
- if (/^gpgsig /m.test(header)) return { signed: true, format: 'OpenPGP' };
14
- return none;
15
- }
7
+ const lf = String.fromCharCode(10);
8
+ const boundary = raw.indexOf(lf + lf);
9
+ const header = boundary === -1 ? raw : raw.slice(0,boundary);
10
+ const signatures = [];
11
+ let current;
12
+ for (const line of header.split(lf)) {
13
+ if (line.startsWith(' ')) {
14
+ if (current) current.push(line.slice(1));
15
+ continue;
16
+ }
17
+ current = undefined;
18
+ const match = /^(?:gpgsig|gpgsig-sha256) (.*)$/.exec(line);
19
+ if (match) {current = [match[1]]; signatures.push(current);}
20
+ }
21
+ if (!signatures.length) return none;
22
+ for (const signature of signatures) {
23
+ const armor = signature.join(lf).trimStart();
24
+ if (armor.startsWith('-----BEGIN SSH SIGNATURE-----' + lf)) return {signed:true,format:'SSH'};
25
+ if (armor.startsWith('-----BEGIN PGP SIGNATURE-----' + lf)) return {signed:true,format:'OpenPGP'};
26
+ }
27
+ return {signed:true,format:'Unknown'};
28
+ }
@@ -0,0 +1,139 @@
1
+ import {isObjectId} from '../git-history.mjs';
2
+
3
+ export const HISTORICAL_CACHE_LIMITS = Object.freeze({maxBytes: 32 * 1024 * 1024, maxEntries: 8, ttlMs: 60000});
4
+ const MAX_BLOB_BYTES = 16 * 1024 * 1024;
5
+ const MAX_LIST_BYTES = 16 * 1024 * 1024;
6
+ const MAX_DIRECTORY_ENTRIES = 100000;
7
+ const CHUNK_BYTES = 64 * 1024;
8
+
9
+ export function validateHistoricalPath(file, allowRoot = false) {
10
+ if (typeof file !== 'string' || file.length > 32768 || (!file && !allowRoot) || file.includes('\0') || file.startsWith('/') || file.split('/').some(part => (!part || part === '.' || part === '..') && !(allowRoot && file === ''))) {
11
+ throw Error('Invalid historical path');
12
+ }
13
+ }
14
+
15
+ export function parseTreeEntries(buffer, prefix = '', maxEntries = MAX_DIRECTORY_ENTRIES) {
16
+ if (!buffer.length) return [];
17
+ const fields = buffer.toString('utf8').split('\0');
18
+ if (fields.pop() !== '') throw Error('Truncated historical tree');
19
+ if (fields.length > maxEntries) throw Error('Historical directory exceeds entry limit');
20
+ const paths = new Set();
21
+ return fields.map(record => {
22
+ const tab = record.indexOf('\t');
23
+ const match = /^(040000|100644|100755|120000|160000) (tree|blob|commit) ([a-f0-9]{40}|[a-f0-9]{64})$/.exec(record.slice(0, tab));
24
+ const file = record.slice(tab + 1);
25
+ if (tab < 0 || !file || !match) throw Error('Invalid historical tree entry');
26
+ validateHistoricalPath(file);
27
+ const [,mode,type,oid] = match;
28
+ if (type !== (mode === '040000' ? 'tree' : mode === '160000' ? 'commit' : 'blob')) throw Error('Invalid historical tree type');
29
+ const fullPath = prefix + file;
30
+ if (paths.has(fullPath)) throw Error('Duplicate historical tree entry');
31
+ paths.add(fullPath);
32
+ return {path: fullPath, mode, type, oid};
33
+ });
34
+ }
35
+
36
+ /** Object cache only, never an authorization cache. Callers must revalidate the repository first. */
37
+ export function createHistoricalAccess({run, now = Date.now, maxBytes = HISTORICAL_CACHE_LIMITS.maxBytes, maxEntries = HISTORICAL_CACHE_LIMITS.maxEntries, ttlMs = HISTORICAL_CACHE_LIMITS.ttlMs} = {}) {
38
+ if (typeof run !== 'function' || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > HISTORICAL_CACHE_LIMITS.maxBytes || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || maxEntries > HISTORICAL_CACHE_LIMITS.maxEntries || !Number.isSafeInteger(ttlMs) || ttlMs < 1 || ttlMs > HISTORICAL_CACHE_LIMITS.ttlMs) throw Error('Invalid historical cache configuration');
39
+ const cache = new Map();
40
+ let bytes = 0, reservedBytes = 0, reservedEntries = 0, epoch = 0, blobReads = 0, blobReadBytes = 0, decodeCount = 0, cacheHits = 0;
41
+ const remove = key => {
42
+ const entry = cache.get(key);
43
+ if (!entry) return;
44
+ clearTimeout(entry.timer);
45
+ bytes -= entry.buffer.length;
46
+ cache.delete(key);
47
+ };
48
+ const purge = () => {for (const [key,entry] of cache) if (entry.expiresAt <= now()) remove(key);};
49
+ const clear = root => {
50
+ epoch++; // In-flight reads predating revocation must never repopulate the cache.
51
+ for (const [key,entry] of cache) if (root === undefined || root === entry.root) remove(key);
52
+ };
53
+ const ensureCommit = async (root, commit, signal) => {
54
+ signal?.throwIfAborted();
55
+ if (!isObjectId(commit)) throw Error('Invalid commit id');
56
+ const type = (await run(root, ['cat-file','-t',commit], 2097152, {signal})).toString('utf8').trim();
57
+ if (type !== 'commit') throw Error('Commit object required');
58
+ };
59
+ const lookup = async (root, {commit,path:file,signal,missingMessage = 'Path is not part of selected commit'}) => {
60
+ validateHistoricalPath(file);
61
+ await ensureCommit(root, commit, signal);
62
+ const listing = await run(root, ['ls-tree','-z','--full-tree',commit,'--',file], 2097152, {signal});
63
+ const entries = parseTreeEntries(listing);
64
+ const entry = entries.find(row => row.path === file);
65
+ if (!entry) throw Error(missingMessage);
66
+ signal?.throwIfAborted();
67
+ return entry;
68
+ };
69
+ const treePage = async (root, {commit,path:directory = '',offset = 0,limit = 100,query = '',signal} = {}) => {
70
+ validateHistoricalPath(directory, true);
71
+ if (typeof query !== 'string' || query.length > 1024 || /[\0\r\n]/.test(query)) throw Error('Invalid historical tree query');
72
+ if (!Number.isInteger(offset) || offset < 0 || offset > MAX_DIRECTORY_ENTRIES || !Number.isInteger(limit) || limit < 1 || limit > 100) throw Error('Invalid tree pagination');
73
+ let tree = commit;
74
+ if (directory) {
75
+ const entry = await lookup(root, {commit,path:directory,signal});
76
+ if (entry.type !== 'tree' || entry.mode !== '040000') throw Error('Historical directory required');
77
+ tree = entry.oid;
78
+ } else await ensureCommit(root, commit, signal);
79
+ // The revision is an already-validated OID, never user-controlled rev:path syntax.
80
+ const buffer = await run(root, ['ls-tree','-z',tree], MAX_LIST_BYTES, {signal});
81
+ const entries = parseTreeEntries(buffer, directory ? directory + '/' : '');
82
+ if (entries.some(entry => entry.path.slice(directory ? directory.length + 1 : 0).includes('/'))) throw Error('Expected direct historical directory entries');
83
+ signal?.throwIfAborted();
84
+ const needle = query.toLowerCase();
85
+ const prefixLength = directory ? directory.length + 1 : 0;
86
+ const filtered = needle ? entries.filter(entry => entry.path.slice(prefixLength).toLowerCase().includes(needle)) : entries;
87
+ filtered.sort((a,b) => Number(b.type === 'tree') - Number(a.type === 'tree') || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
88
+ return {commit,path:directory,query,entries:filtered.slice(offset,offset + limit),nextOffset:offset + limit < filtered.length ? offset + limit : null};
89
+ };
90
+ const segment = async (root, {commit,path:file,offset = 0,signal,expectedEpoch = epoch} = {}) => {
91
+ const generation = expectedEpoch; // Includes caller repository validation and all member lookups.
92
+ if (!Number.isInteger(offset) || offset < 0 || offset > MAX_BLOB_BYTES) throw Error('Invalid segment offset');
93
+ const entry = await lookup(root, {commit,path:file,signal,missingMessage:'Segment requires a selected committed regular file'});
94
+ if (entry.type !== 'blob' || !['100644','100755'].includes(entry.mode)) throw Error('Segment requires a selected committed regular file');
95
+ purge();
96
+ const key = JSON.stringify([root,commit,entry.oid]);
97
+ let buffer = generation === epoch ? cache.get(key)?.buffer : undefined;
98
+ if (buffer) cacheHits++;
99
+ if (!buffer) {
100
+ const rawSize = (await run(root, ['cat-file','-s',entry.oid], 2097152, {signal})).toString('utf8').trim();
101
+ if (!/^\d+$/.test(rawSize)) throw Error('Invalid object size');
102
+ const size = Number(rawSize);
103
+ if (!Number.isSafeInteger(size) || size > MAX_BLOB_BYTES) throw Error('Segmented file exceeds 16 MiB limit');
104
+ if (offset > size) throw Error('Segment offset exceeds file size');
105
+ if (size > maxBytes) throw Error('Segmented file exceeds cache byte budget');
106
+ purge();
107
+ while (cache.size && (bytes + reservedBytes + size > maxBytes || cache.size + reservedEntries >= maxEntries)) remove(cache.keys().next().value);
108
+ if (bytes + reservedBytes + size > maxBytes || cache.size + reservedEntries >= maxEntries) throw Object.assign(Error('Historical cache is busy; retry the read'), {code:'vcs/busy'});
109
+ reservedBytes += size; reservedEntries++;
110
+ try {
111
+ blobReads++;
112
+ buffer = await run(root, ['cat-file','blob',entry.oid], Math.max(1,size), {signal});
113
+ blobReadBytes += buffer.length;
114
+ signal?.throwIfAborted();
115
+ if (buffer.length !== size) throw Error('Historical blob size mismatch');
116
+ if (buffer.includes(0) || buffer.some(byte => byte < 32 && ![9,10,12,13].includes(byte))) throw Error('Segments support UTF-8 text only');
117
+ new TextDecoder('utf-8',{fatal:true}).decode(buffer);
118
+ decodeCount++;
119
+ if (generation === epoch) {
120
+ remove(key);
121
+ const timer = setTimeout(() => remove(key), ttlMs);
122
+ timer.unref?.();
123
+ cache.set(key, {root,buffer,expiresAt:now() + ttlMs,timer});
124
+ bytes += buffer.length;
125
+ }
126
+ } finally {reservedBytes -= size; reservedEntries--;}
127
+ }
128
+ signal?.throwIfAborted();
129
+ const size = buffer.length;
130
+ if (offset > size) throw Error('Segment offset exceeds file size');
131
+ if (offset < size && (buffer[offset] & 192) === 128) throw Error('Segment offset splits UTF-8 character');
132
+ let end = Math.min(size, offset + CHUNK_BYTES);
133
+ while (end < size && (buffer[end] & 192) === 128) end--;
134
+ const text = new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(buffer.subarray(offset,end));
135
+ signal?.throwIfAborted();
136
+ return {commit,path:file,oid:entry.oid,encoding:'UTF-8',offset,endOffset:end,totalBytes:size,nextOffset:end < size ? end : null,text};
137
+ };
138
+ return {lookup,treePage,segment,clear,captureEpoch:() => epoch,stats:() => {purge();return {bytes,entries:cache.size,reservedBytes,reservedEntries,blobReads,blobReadBytes,decodeCount,cacheHits};}};
139
+ }
@@ -0,0 +1,44 @@
1
+ import {AsyncLocalStorage} from 'node:async_hooks';
2
+ import {performance} from 'node:perf_hooks';
3
+
4
+ const operations = new AsyncLocalStorage();
5
+ export const operationTimeout = () => Object.assign(new Error('VCS operation deadline exceeded.'), {code: 'TIMEOUT'});
6
+
7
+ /** One monotonic deadline per RPC. Nested commands never reset this budget. */
8
+ export async function withOperationBudget({signal, timeoutMs = 15000} = {}, work) {
9
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 15000) throw new Error('Invalid operation timeout.');
10
+ const parent = operations.getStore();
11
+ const deadline = Math.min(performance.now() + timeoutMs, parent?.deadline ?? Infinity);
12
+ const controller = new AbortController();
13
+ const signals = [signal, parent?.signal].filter(Boolean);
14
+ const abort = () => {
15
+ const source = signals.find(value => value.aborted);
16
+ if (source) controller.abort(source.reason);
17
+ };
18
+ for (const source of signals) source.addEventListener('abort', abort, {once: true});
19
+ abort();
20
+ const timer = setTimeout(() => controller.abort(operationTimeout()), Math.max(1, Math.ceil(deadline - performance.now())));
21
+ const budget = {
22
+ deadline,
23
+ signal: controller.signal,
24
+ remaining() {
25
+ this.throwIfExpired();
26
+ return Math.max(1, Math.ceil(deadline - performance.now()));
27
+ },
28
+ throwIfExpired() {
29
+ if (!controller.signal.aborted && performance.now() >= deadline) controller.abort(operationTimeout());
30
+ controller.signal.throwIfAborted();
31
+ },
32
+ };
33
+ try {
34
+ budget.throwIfExpired();
35
+ const value = await operations.run(budget, work);
36
+ budget.throwIfExpired();
37
+ return value;
38
+ } finally {
39
+ clearTimeout(timer);
40
+ for (const source of signals) source.removeEventListener('abort', abort);
41
+ }
42
+ }
43
+
44
+ export const currentOperationBudget = () => operations.getStore();
@@ -0,0 +1,19 @@
1
+ import {MAX_TEXT,MAX_LARGE_TEXT} from '../text-content.mjs';
2
+
3
+ /** Machine-readable capability contract. Human notices never decide an action. */
4
+ export function comparisonPreview({left = {},right = {},large = false,allowLarge = false,conflicted = false} = {}) {
5
+ const sides = [left,right];
6
+ const reason = sides.some(side => side.binary) ? 'binary'
7
+ : sides.some(side => side.previewReason === 'size-limit') ? 'size-limit'
8
+ : sides.find(side => side.previewReason)?.previewReason ?? null;
9
+ return {
10
+ reason,
11
+ canLoadLarge: !large && allowLarge && !conflicted && reason === 'size-limit' && sides.every(side => side.largeEligible !== false),
12
+ complete: reason === null,
13
+ limits: {previewBytes:MAX_TEXT,largeBytes:MAX_LARGE_TEXT},
14
+ };
15
+ }
16
+
17
+ export function withComparisonPreview(comparison, options = {}) {
18
+ return {...comparison,preview:comparisonPreview({...options,large:comparison.large ?? options.large,left:options.left ?? comparison.left,right:options.right ?? comparison.right})};
19
+ }