@haystackeditor/cli 0.17.1 → 0.19.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.
@@ -1,139 +0,0 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- const maximumBytes = 8 * 1024 * 1024;
4
- const configPath = '.haystack/combination-search.json';
5
- function hasHexSuffix(value, prefix, length) {
6
- return typeof value === 'string' && value.length === prefix.length + length && value.startsWith(prefix)
7
- && [...value.slice(prefix.length)].every(character => '0123456789abcdef'.includes(character));
8
- }
9
- function record(value) {
10
- if (!value || typeof value !== 'object' || Array.isArray(value))
11
- throw new Error('Invalid combination-search configuration.');
12
- return value;
13
- }
14
- function keys(value, expected) {
15
- if (Object.keys(value).length !== expected.length || expected.some(key => !Object.hasOwn(value, key)))
16
- throw new Error('Invalid combination-search fields.');
17
- }
18
- function integer(value, minimum, maximum) {
19
- if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum)
20
- throw new Error('Invalid combination-search limit.');
21
- return value;
22
- }
23
- function settings(value) {
24
- const row = record(value);
25
- keys(row, ['budgetMs', 'maxUniversesInFlight', 'picksPerBatch', 'seed', 'rankingPoolSize']);
26
- const picks = record(row.picksPerBatch);
27
- keys(picks, ['ranked', 'baseline']);
28
- const ranked = integer(picks.ranked, 0, 500), baseline = integer(picks.baseline, 0, 500);
29
- if (ranked + baseline < 1 || ranked + baseline > 500)
30
- throw new Error('Invalid combination-search picks.');
31
- return { budgetMs: integer(row.budgetMs, 10000, 1800000), maxUniversesInFlight: integer(row.maxUniversesInFlight, 1, 500),
32
- picksPerBatch: { ranked, baseline }, seed: integer(row.seed, 0, 0xffffffff), rankingPoolSize: integer(row.rankingPoolSize, 1, 100000) };
33
- }
34
- function boundedJson(value) {
35
- const visit = (item, depth) => {
36
- if (depth > 64)
37
- throw new Error('Combination-search input is too deeply nested.');
38
- if (item === null || typeof item === 'string' || typeof item === 'boolean')
39
- return;
40
- if (typeof item === 'number' && Number.isFinite(item))
41
- return;
42
- if (Array.isArray(item)) {
43
- for (const entry of item)
44
- visit(entry, depth + 1);
45
- return;
46
- }
47
- for (const [key, entry] of Object.entries(record(item))) {
48
- if (['__proto__', 'constructor', 'prototype'].includes(key))
49
- throw new Error('Invalid combination-search JSON key.');
50
- visit(entry, depth + 1);
51
- }
52
- };
53
- visit(value, 0);
54
- if (Buffer.byteLength(JSON.stringify(value)) > maximumBytes)
55
- throw new Error('Combination-search input exceeds 8 MiB.');
56
- return value;
57
- }
58
- function configuration(value) {
59
- const row = record(value);
60
- keys(row, ['schemaVersion', 'combinations', 'prIntent', 'search', 'ideas']);
61
- const ideas = record(row.ideas);
62
- keys(ideas, ['count']);
63
- if (row.schemaVersion !== 1 || typeof row.prIntent !== 'string' || !row.prIntent || row.prIntent.length > 65536)
64
- throw new Error('Invalid combination-search intent.');
65
- return { schemaVersion: 1, combinations: boundedJson(row.combinations), prIntent: row.prIntent,
66
- search: settings(row.search), ideas: { count: integer(ideas.count, 1, 100) } };
67
- }
68
- /** Git reads only the captured immutable commit. Local changes made while the
69
- * detached sender runs cannot alter the search's source or combination space. */
70
- export function captureCombinationSearchHook(gitRoot, repository, baseCommit, headCommit, deadline) {
71
- const git = (args) => {
72
- const remaining = deadline - Date.now();
73
- if (remaining <= 0)
74
- throw new Error('Combination-search capture timed out.');
75
- return execFileSync('git', ['--literal-pathspecs', ...args], { cwd: gitRoot, encoding: 'utf8',
76
- timeout: remaining, maxBuffer: maximumBytes, stdio: ['ignore', 'pipe', 'pipe'] });
77
- };
78
- const present = git(['ls-tree', '-z', '--name-only', headCommit, '--', configPath]);
79
- if (!present)
80
- return undefined;
81
- if (present !== `${configPath}\0`)
82
- throw new Error('Unexpected combination-search configuration identity.');
83
- const config = configuration(JSON.parse(git(['show', `${headCommit}:${configPath}`])));
84
- const stats = git(['diff', '--no-renames', '--numstat', '-z', baseCommit, headCommit, '--']);
85
- const files = stats.split('\0').filter(Boolean).map(stat => {
86
- const first = stat.indexOf('\t'), second = stat.indexOf('\t', first + 1);
87
- if (first < 0 || second < 0)
88
- throw new Error('Invalid captured diff metadata.');
89
- const file = stat.slice(second + 1);
90
- const count = (raw) => raw === '-' ? 0 : integer(Number(raw), 0, Number.MAX_SAFE_INTEGER);
91
- return { file, additions: count(stat.slice(0, first)), deletions: count(stat.slice(first + 1, second)),
92
- patch: git(['diff', '--no-renames', '--binary', '--no-ext-diff', '--no-textconv', baseCommit, headCommit, '--', file]) };
93
- });
94
- const inputs = { repository, baseCommit, headCommit, combinationsRef: { inline: config.combinations },
95
- change: { diffRef: { inline: { sha: headCommit, parent: baseCommit, message: config.prIntent, files } },
96
- prIntentRef: { inline: config.prIntent } }, search: config.search, ideas: config.ideas };
97
- boundedJson(inputs);
98
- return { ...inputs, idempotencyKey: `search:${createHash('sha256').update(JSON.stringify(inputs)).digest('hex')}` };
99
- }
100
- export function validateCapturedSearch(value, identity) {
101
- boundedJson(value);
102
- const row = record(value);
103
- keys(row, ['repository', 'baseCommit', 'headCommit', 'idempotencyKey', 'combinationsRef', 'change', 'search', 'ideas']);
104
- if (row.repository !== `${identity.owner}/${identity.repository}` || row.baseCommit !== identity.baseCommit || row.headCommit !== identity.workCommit
105
- || !hasHexSuffix(row.idempotencyKey, 'search:', 64))
106
- throw new Error('Captured search source identity differs.');
107
- settings(row.search);
108
- const combinations = record(row.combinationsRef);
109
- keys(combinations, ['inline']);
110
- const change = record(row.change);
111
- keys(change, ['diffRef', 'prIntentRef']);
112
- const diffRef = record(change.diffRef);
113
- keys(diffRef, ['inline']);
114
- const diff = record(diffRef.inline);
115
- if (diff.sha !== identity.workCommit || diff.parent !== identity.baseCommit)
116
- throw new Error('Captured search diff identity differs.');
117
- const prIntent = record(change.prIntentRef);
118
- keys(prIntent, ['inline']);
119
- if (typeof prIntent.inline !== 'string' || !prIntent.inline)
120
- throw new Error('Captured search intent is missing.');
121
- const ideas = record(row.ideas);
122
- keys(ideas, ['count']);
123
- integer(ideas.count, 1, 100);
124
- const { idempotencyKey, ...inputs } = row;
125
- if (idempotencyKey !== `search:${createHash('sha256').update(JSON.stringify(inputs)).digest('hex')}`)
126
- throw new Error('Captured search digest differs.');
127
- }
128
- export function readCombinationSearchHookStatus(value) {
129
- const row = record(value);
130
- if ((row.status === 'queued' || row.status === 'existing') && hasHexSuffix(row.runId, 'cv_', 48)) {
131
- keys(row, ['status', 'runId']);
132
- return { status: row.status, runId: row.runId };
133
- }
134
- if (row.status === 'unavailable' && ['not-configured', 'runner-unavailable', 'enqueue-failed'].includes(String(row.reason))) {
135
- keys(row, ['status', 'reason']);
136
- return { status: row.status, reason: row.reason };
137
- }
138
- throw new Error('Invalid combination-search acknowledgment.');
139
- }