@remogram/core 0.1.0-beta.0 → 0.1.0-beta.10

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.
Files changed (45) hide show
  1. package/auth-classes.js +73 -0
  2. package/branch-protection.js +155 -0
  3. package/caps.js +185 -6
  4. package/change-request-merge-execute.js +148 -0
  5. package/check-diagnostics.js +92 -0
  6. package/check-pagination.js +216 -0
  7. package/config-schema.js +21 -0
  8. package/contracts/envelope.js +26 -2
  9. package/contracts/errors.js +14 -2
  10. package/contracts/forge-error-fields.js +124 -0
  11. package/contracts/observer-fact-inventory.js +64 -0
  12. package/contracts/semantic-diff-facts.js +168 -0
  13. package/cr-comments.js +93 -0
  14. package/cr-files.js +62 -0
  15. package/cr-inventory-cursor.js +64 -0
  16. package/cr-inventory.js +136 -0
  17. package/cr-open.js +38 -0
  18. package/effective-write-policy.js +68 -0
  19. package/forge-changes-cursor.js +88 -0
  20. package/forge-changes.js +181 -0
  21. package/forge-identity.js +42 -0
  22. package/git-args.js +19 -0
  23. package/git-local.js +22 -0
  24. package/http.js +83 -4
  25. package/idempotency.js +69 -0
  26. package/index.js +266 -4
  27. package/issue-open.js +50 -0
  28. package/merge-blockers.js +68 -0
  29. package/merge-plan-forge.js +63 -0
  30. package/merge-plan.js +82 -0
  31. package/merge-policy.js +55 -0
  32. package/open-pull-list.js +256 -0
  33. package/operator-config.js +260 -0
  34. package/package.json +1 -1
  35. package/path-allowlist.js +114 -0
  36. package/pr-head-reconcile.js +38 -0
  37. package/provider-health.js +93 -0
  38. package/ref-inventory.js +98 -0
  39. package/resolve.js +53 -4
  40. package/status-set.js +92 -0
  41. package/stub-provider.js +11 -8
  42. package/whoami.js +114 -0
  43. package/write-config.js +63 -0
  44. package/write-field-policy.js +93 -0
  45. package/write-readiness.js +91 -0
@@ -0,0 +1,88 @@
1
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
+ import { parseSinceObservedAt } from './forge-changes.js';
3
+
4
+ export const FORGE_CHANGES_CURSOR_VERSION = 1;
5
+ export const DEFAULT_FORGE_CHANGES_PAGE_SIZE = 64;
6
+
7
+ /**
8
+ * @param {{ since: string, offset: number }} state
9
+ * @returns {string}
10
+ */
11
+ export function encodeForgeChangesCursor(state) {
12
+ const since = parseSinceObservedAt(state.since);
13
+ const offset = Number(state.offset);
14
+ if (!Number.isInteger(offset) || offset < 0) {
15
+ throw Object.assign(new Error('Invalid forge changes cursor offset'), {
16
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'cursor offset must be a non-negative integer'),
17
+ });
18
+ }
19
+ const payload = JSON.stringify({ v: FORGE_CHANGES_CURSOR_VERSION, since, offset });
20
+ return Buffer.from(payload, 'utf8').toString('base64url');
21
+ }
22
+
23
+ /**
24
+ * @param {unknown} raw
25
+ * @param {{ since?: string }} [opts]
26
+ * @returns {{ since: string, offset: number }}
27
+ */
28
+ export function decodeForgeChangesCursor(raw, opts = {}) {
29
+ if (raw == null || raw === '') {
30
+ throw Object.assign(new Error('Missing forge changes cursor'), {
31
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor must not be empty when provided'),
32
+ });
33
+ }
34
+ let decoded;
35
+ try {
36
+ decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
37
+ } catch {
38
+ throw Object.assign(new Error('Invalid forge changes cursor'), {
39
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor is malformed or not base64url JSON'),
40
+ });
41
+ }
42
+ if (decoded?.v !== FORGE_CHANGES_CURSOR_VERSION) {
43
+ throw Object.assign(new Error('Invalid forge changes cursor version'), {
44
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor version is unsupported'),
45
+ });
46
+ }
47
+ const since = parseSinceObservedAt(decoded.since);
48
+ const offset = Number(decoded.offset);
49
+ if (!Number.isInteger(offset) || offset < 0) {
50
+ throw Object.assign(new Error('Invalid forge changes cursor offset'), {
51
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor offset must be a non-negative integer'),
52
+ });
53
+ }
54
+ if (opts.since != null && opts.since !== '') {
55
+ const requestedSince = parseSinceObservedAt(opts.since);
56
+ if (requestedSince !== since) {
57
+ throw Object.assign(new Error('Forge changes cursor since mismatch'), {
58
+ forgeError: forgeError(
59
+ ERROR_CODES.INVALID_ARGS,
60
+ '--since must match the cursor since when both are provided',
61
+ ),
62
+ });
63
+ }
64
+ }
65
+ return { since, offset };
66
+ }
67
+
68
+ /**
69
+ * @param {object} body
70
+ * @param {{ offset?: number, limit?: number }} [opts]
71
+ */
72
+ export function paginateForgeChangesBody(body, opts = {}) {
73
+ const offset = opts.offset ?? 0;
74
+ const limit = opts.limit ?? DEFAULT_FORGE_CHANGES_PAGE_SIZE;
75
+ const allEvents = Array.isArray(body.events) ? body.events : [];
76
+ const pageEvents = allEvents.slice(offset, offset + limit);
77
+ const observedEnd = offset + pageEvents.length;
78
+ const hasMore = observedEnd < allEvents.length || body.events_truncated === true;
79
+ return {
80
+ ...body,
81
+ events: pageEvents,
82
+ has_more: hasMore,
83
+ complete: !hasMore,
84
+ ...(hasMore
85
+ ? { next_cursor: encodeForgeChangesCursor({ since: body.since, offset: observedEnd }) }
86
+ : {}),
87
+ };
88
+ }
@@ -0,0 +1,181 @@
1
+ import { sanitizeField, sanitizeUrl } from './caps.js';
2
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
+
4
+ export const MAX_FORGE_CHANGES_EVENTS = 256;
5
+
6
+ export const FORGE_CHANGE_EVENT_KINDS = Object.freeze({
7
+ PR_OPENED: 'pr_opened',
8
+ PR_CLOSED: 'pr_closed',
9
+ PR_MERGED: 'pr_merged',
10
+ HEAD_SHA_MOVED: 'head_sha_moved',
11
+ CHECKS_CONCLUSION_OBSERVED: 'checks_conclusion_observed',
12
+ });
13
+
14
+ export function parseSinceObservedAt(raw) {
15
+ if (raw == null || String(raw).trim() === '') {
16
+ throw Object.assign(new Error('--since required'), {
17
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--since required for forge changes'),
18
+ });
19
+ }
20
+ const ms = Date.parse(String(raw));
21
+ if (!Number.isFinite(ms)) {
22
+ throw Object.assign(new Error('Invalid --since'), {
23
+ forgeError: forgeError(
24
+ ERROR_CODES.INVALID_ARGS,
25
+ '--since must be a parseable ISO-8601 timestamp',
26
+ ),
27
+ });
28
+ }
29
+ return new Date(ms).toISOString();
30
+ }
31
+
32
+ function timestampMs(value) {
33
+ const ms = Date.parse(value ?? '');
34
+ return Number.isFinite(ms) ? ms : null;
35
+ }
36
+
37
+ function isAtOrAfter(value, sinceIso) {
38
+ const ms = timestampMs(value);
39
+ const sinceMs = timestampMs(sinceIso);
40
+ if (ms == null || sinceMs == null) return false;
41
+ return ms >= sinceMs;
42
+ }
43
+
44
+ function isBefore(value, sinceIso) {
45
+ const ms = timestampMs(value);
46
+ const sinceMs = timestampMs(sinceIso);
47
+ if (ms == null || sinceMs == null) return false;
48
+ return ms < sinceMs;
49
+ }
50
+
51
+ function normalizePrState(state) {
52
+ const normalized = String(state ?? '').toLowerCase();
53
+ if (normalized === 'open') return 'open';
54
+ if (normalized === 'closed') return 'closed';
55
+ return 'unknown';
56
+ }
57
+
58
+ function normalizeIsoTimestamp(value) {
59
+ const ms = timestampMs(value);
60
+ return ms == null ? null : new Date(ms).toISOString();
61
+ }
62
+
63
+ function baseEventFields(pull) {
64
+ return {
65
+ pr_number: Math.floor(Number(pull.number)),
66
+ title: sanitizeField(pull.title ?? '') || null,
67
+ url: sanitizeUrl(pull.html_url ?? pull.url) || null,
68
+ };
69
+ }
70
+
71
+ export function buildChecksConclusionObservedEvent(prNumber, checksBody) {
72
+ return {
73
+ kind: FORGE_CHANGE_EVENT_KINDS.CHECKS_CONCLUSION_OBSERVED,
74
+ pr_number: Math.floor(Number(prNumber)),
75
+ forge_source_sha: sanitizeField(checksBody?.forge_source_sha ?? '') || null,
76
+ check_conclusion: sanitizeField(checksBody?.check_conclusion ?? '') || 'unknown',
77
+ checks_truncated: Boolean(checksBody?.checks_truncated ?? false),
78
+ };
79
+ }
80
+
81
+ export function buildForgeChangesBody({
82
+ since,
83
+ events,
84
+ events_truncated,
85
+ event_count,
86
+ since_kind = 'observed_at',
87
+ }) {
88
+ const list = Array.isArray(events) ? events : [];
89
+ const count = Number.isFinite(Number(event_count))
90
+ ? Math.floor(Number(event_count))
91
+ : list.length;
92
+ return {
93
+ since,
94
+ since_kind,
95
+ events: list,
96
+ events_truncated: Boolean(events_truncated),
97
+ event_count: count,
98
+ };
99
+ }
100
+
101
+ function capForgeChangeEvents(allEvents, sinceIso, { listTruncated = false } = {}) {
102
+ const event_count = allEvents.length;
103
+ const capped = allEvents.length > MAX_FORGE_CHANGES_EVENTS;
104
+ return buildForgeChangesBody({
105
+ since: sinceIso,
106
+ events: allEvents.slice(0, MAX_FORGE_CHANGES_EVENTS),
107
+ events_truncated: capped || listTruncated,
108
+ event_count,
109
+ });
110
+ }
111
+
112
+ export function appendForgeChangeEvents(body, additionalEvents, { listTruncated = false } = {}) {
113
+ const merged = [...(body.events ?? []), ...(Array.isArray(additionalEvents) ? additionalEvents : [])];
114
+ return capForgeChangeEvents(merged, body.since, {
115
+ listTruncated: listTruncated || body.events_truncated,
116
+ });
117
+ }
118
+
119
+ export function buildForgeChangesFromGiteaPulls(sinceIso, pullsArray, opts = {}) {
120
+ const since = parseSinceObservedAt(sinceIso);
121
+ const events = [];
122
+ if (Array.isArray(pullsArray)) {
123
+ for (const pull of pullsArray) {
124
+ if (pull == null || pull.number == null) continue;
125
+ const state = normalizePrState(pull.state);
126
+ const base = baseEventFields(pull);
127
+ const createdAt = pull.created_at;
128
+ const updatedAt = pull.updated_at;
129
+ const closedAt = pull.closed_at;
130
+ const mergedAt = pull.merged_at;
131
+ const mergedInWindow = isAtOrAfter(mergedAt, since);
132
+
133
+ if (isAtOrAfter(createdAt, since)) {
134
+ events.push({
135
+ kind: FORGE_CHANGE_EVENT_KINDS.PR_OPENED,
136
+ ...base,
137
+ state,
138
+ opened_at: normalizeIsoTimestamp(createdAt),
139
+ });
140
+ }
141
+
142
+ if (mergedInWindow) {
143
+ events.push({
144
+ kind: FORGE_CHANGE_EVENT_KINDS.PR_MERGED,
145
+ ...base,
146
+ state: 'closed',
147
+ merged_at: normalizeIsoTimestamp(mergedAt),
148
+ });
149
+ }
150
+
151
+ const closedNotMerged =
152
+ state === 'closed' && (mergedAt == null || String(mergedAt).trim() === '');
153
+ const closedInWindow =
154
+ isAtOrAfter(closedAt, since) ||
155
+ (closedAt == null && closedNotMerged && isAtOrAfter(updatedAt, since));
156
+ if (!mergedInWindow && closedNotMerged && closedInWindow) {
157
+ events.push({
158
+ kind: FORGE_CHANGE_EVENT_KINDS.PR_CLOSED,
159
+ ...base,
160
+ state: 'closed',
161
+ closed_at: normalizeIsoTimestamp(closedAt ?? updatedAt),
162
+ });
163
+ }
164
+
165
+ if (
166
+ state === 'open' &&
167
+ isAtOrAfter(updatedAt, since) &&
168
+ isBefore(createdAt, since)
169
+ ) {
170
+ events.push({
171
+ kind: FORGE_CHANGE_EVENT_KINDS.HEAD_SHA_MOVED,
172
+ ...base,
173
+ state: 'open',
174
+ forge_source_sha: sanitizeField(pull.head?.sha ?? '') || null,
175
+ updated_at: normalizeIsoTimestamp(updatedAt),
176
+ });
177
+ }
178
+ }
179
+ }
180
+ return capForgeChangeEvents(events, since, { listTruncated: Boolean(opts.listTruncated) });
181
+ }
@@ -0,0 +1,42 @@
1
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
+
3
+ /**
4
+ * Canonical forge origin (scheme + host + port) from configured baseUrl.
5
+ * Config-derived trusted identity — not forge-sourced.
6
+ *
7
+ * @param {{ baseUrl?: string }} config
8
+ * @returns {string | null}
9
+ */
10
+ export function normalizedForgeOrigin(config) {
11
+ if (!config?.baseUrl) return null;
12
+ let url;
13
+ try {
14
+ url = new URL(config.baseUrl);
15
+ } catch {
16
+ throw Object.assign(new Error('Invalid baseUrl'), {
17
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'Invalid baseUrl in .remogram.json'),
18
+ });
19
+ }
20
+ if (url.username || url.password) {
21
+ throw Object.assign(new Error('baseUrl must not contain userinfo'), {
22
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'baseUrl must not contain userinfo'),
23
+ });
24
+ }
25
+ if (url.pathname && url.pathname !== '/') {
26
+ throw Object.assign(new Error('baseUrl must be a forge origin'), {
27
+ forgeError: forgeError(
28
+ ERROR_CODES.CONFIG_INVALID,
29
+ 'baseUrl must be a forge origin without a path',
30
+ ),
31
+ });
32
+ }
33
+ if (url.search || url.hash) {
34
+ throw Object.assign(new Error('baseUrl must be a forge origin'), {
35
+ forgeError: forgeError(
36
+ ERROR_CODES.CONFIG_INVALID,
37
+ 'baseUrl must be a forge origin without query or fragment',
38
+ ),
39
+ });
40
+ }
41
+ return url.origin;
42
+ }
package/git-args.js CHANGED
@@ -35,3 +35,22 @@ export function assertGitRemote(name, label = 'remote') {
35
35
  throw invalidArgs(`${label} contains invalid characters`);
36
36
  }
37
37
  }
38
+
39
+ const CR_OPEN_REMOTE_PREFIXES = new Set(['origin', 'upstream', 'gitea', 'github', 'gitlab']);
40
+
41
+ /**
42
+ * cr open --head/--base expect forge branch names, not git remote/ref pairs.
43
+ */
44
+ export function assertCrOpenBranchRef(ref, label = 'ref') {
45
+ assertGitRef(ref, label);
46
+ const slash = ref.indexOf('/');
47
+ if (slash <= 0) return;
48
+ const remotePrefix = ref.slice(0, slash);
49
+ if (!CR_OPEN_REMOTE_PREFIXES.has(remotePrefix)) return;
50
+ const branchHint = ref.slice(slash + 1);
51
+ throw invalidArgs(
52
+ `${label} looks like a remote/ref (${ref}); cr open expects a forge branch name `
53
+ + `(e.g. ${branchHint}). Compare forge_target_branch_ref from pr view to your workflow `
54
+ + 'canonical_integration_ref outside Remogram.',
55
+ );
56
+ }
package/git-local.js CHANGED
@@ -24,7 +24,17 @@ export function gitCurrentBranch(cwd) {
24
24
  }
25
25
  }
26
26
 
27
+ export function gitRepoRoot(cwd) {
28
+ try {
29
+ return gitExec(cwd, ['rev-parse', '--show-toplevel']);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
27
35
  export function gitAheadBehind(cwd, base, head) {
36
+ assertGitRef(base, 'base');
37
+ assertGitRef(head, 'head');
28
38
  try {
29
39
  const out = gitExec(cwd, ['rev-list', '--left-right', '--count', `${base}...${head}`]);
30
40
  const [behind, ahead] = out.split(/\s+/).map(Number);
@@ -33,3 +43,15 @@ export function gitAheadBehind(cwd, base, head) {
33
43
  return { ahead_by: null, behind_by: null };
34
44
  }
35
45
  }
46
+
47
+ export function gitDiffNameOnly(cwd, base, head) {
48
+ assertGitRef(base, 'base');
49
+ assertGitRef(head, 'head');
50
+ try {
51
+ const out = gitExec(cwd, ['diff', '--name-only', base, head]);
52
+ if (!out) return [];
53
+ return out.split('\n').filter(Boolean);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
package/http.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
- import { readStreamCapped, DEFAULT_MAX_BYTES, sanitizeField } from './caps.js';
2
+ import { readStreamCapped, getEffectiveIngestMaxBytes, sanitizeField } from './caps.js';
3
3
 
4
4
  const DEFAULT_TIMEOUT_MS = 30_000;
5
- const DEFAULT_JSON_MAX_BYTES = DEFAULT_MAX_BYTES;
5
+ const DEFAULT_JSON_MAX_BYTES = () => getEffectiveIngestMaxBytes().bytes;
6
6
 
7
7
  export async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
8
8
  const signal = AbortSignal.timeout(timeoutMs);
@@ -25,7 +25,7 @@ export async function fetchJson(
25
25
  url,
26
26
  options = {},
27
27
  timeoutMs = DEFAULT_TIMEOUT_MS,
28
- maxBytes = DEFAULT_JSON_MAX_BYTES,
28
+ maxBytes = DEFAULT_JSON_MAX_BYTES(),
29
29
  ) {
30
30
  const res = await fetchWithTimeout(url, options, timeoutMs);
31
31
  if (res.status >= 300 && res.status < 400) {
@@ -56,7 +56,86 @@ export async function fetchJson(
56
56
  return body;
57
57
  }
58
58
 
59
- export async function fetchTextCapped(url, options = {}, maxBytes = DEFAULT_MAX_BYTES) {
59
+ export function parseLinkHeader(linkHeader) {
60
+ if (!linkHeader) return {};
61
+ const links = {};
62
+ for (const segment of String(linkHeader).split(',')) {
63
+ const match = segment.trim().match(/^<([^>]+)>;\s*rel=(?:"([^"]+)"|'([^']+)')/);
64
+ if (match) links[(match[2] ?? match[3]).toLowerCase()] = match[1];
65
+ }
66
+ return links;
67
+ }
68
+
69
+ function normalizePathname(pathname) {
70
+ if (pathname.length > 1 && pathname.endsWith('/')) {
71
+ return pathname.replace(/\/+$/, '') || '/';
72
+ }
73
+ return pathname;
74
+ }
75
+
76
+ /** Reject Link rel=next URLs that leave the configured API origin (token exfiltration guard). */
77
+ export function isTrustedPaginationUrl(trustedOrigin, url, resolveBase) {
78
+ try {
79
+ if (resolveBase == null || resolveBase === '') {
80
+ return false;
81
+ }
82
+ const baseResolved = new URL(resolveBase);
83
+ if (baseResolved.username !== '' || baseResolved.password !== '') {
84
+ return false;
85
+ }
86
+ const resolved = new URL(url, resolveBase);
87
+ if (resolved.username !== '' || resolved.password !== '') {
88
+ return false;
89
+ }
90
+ if (resolved.origin !== trustedOrigin) {
91
+ return false;
92
+ }
93
+ const basePath = normalizePathname(new URL(resolveBase).pathname);
94
+ if (normalizePathname(resolved.pathname) !== basePath) {
95
+ return false;
96
+ }
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ export async function fetchJsonWithMeta(
104
+ url,
105
+ options = {},
106
+ timeoutMs = DEFAULT_TIMEOUT_MS,
107
+ maxBytes = DEFAULT_JSON_MAX_BYTES(),
108
+ ) {
109
+ const res = await fetchWithTimeout(url, options, timeoutMs);
110
+ if (res.status >= 300 && res.status < 400) {
111
+ const message = 'HTTP redirect rejected';
112
+ throw Object.assign(new Error(message), {
113
+ forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
114
+ status: res.status,
115
+ });
116
+ }
117
+ const text = await readResponseTextCapped(res, maxBytes);
118
+ let body;
119
+ try {
120
+ body = text ? JSON.parse(text) : null;
121
+ } catch {
122
+ throw Object.assign(new Error('Unparseable JSON from provider'), {
123
+ forgeError: forgeError(ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT, 'Provider returned invalid JSON'),
124
+ status: res.status,
125
+ });
126
+ }
127
+ if (!res.ok) {
128
+ const raw = body?.message || body?.error || res.statusText || 'API error';
129
+ const message = sanitizeField(raw) || 'API error';
130
+ throw Object.assign(new Error(message), {
131
+ forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
132
+ status: res.status,
133
+ });
134
+ }
135
+ return { body, headers: res.headers, status: res.status };
136
+ }
137
+
138
+ export async function fetchTextCapped(url, options = {}, maxBytes = getEffectiveIngestMaxBytes().bytes) {
60
139
  const res = await fetchWithTimeout(url, options);
61
140
  if (res.status >= 300 && res.status < 400) {
62
141
  const message = 'HTTP redirect rejected';
package/idempotency.js ADDED
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { sanitizeField } from './caps.js';
3
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
4
+
5
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
6
+
7
+ /** @type {Map<string, string>} */
8
+ const scopeBindings = new Map();
9
+
10
+ export function resetIdempotencyScopeBindings() {
11
+ scopeBindings.clear();
12
+ }
13
+
14
+ export function normalizeIdempotencyKey(value) {
15
+ if (value == null || value === '') return null;
16
+ const key = sanitizeField(String(value).trim());
17
+ if (!key || !KEY_PATTERN.test(key)) {
18
+ throw Object.assign(new Error('Invalid idempotency key'), {
19
+ forgeError: forgeError(
20
+ ERROR_CODES.INVALID_ARGS,
21
+ 'idempotency key must be 1-128 characters from [A-Za-z0-9._:-]',
22
+ ),
23
+ });
24
+ }
25
+ return key;
26
+ }
27
+
28
+ export function idempotencyFingerprint(key, scopeParts = []) {
29
+ const normalizedKey = normalizeIdempotencyKey(key);
30
+ if (!normalizedKey) return null;
31
+ const scope = scopeParts.map((part) => sanitizeField(String(part ?? ''))).join('\0');
32
+ return createHash('sha256').update(`${normalizedKey}\0${scope}`).digest('hex').slice(0, 16);
33
+ }
34
+
35
+ export function idempotencyScopeKey(repoId, key) {
36
+ const normalizedKey = normalizeIdempotencyKey(key);
37
+ if (!normalizedKey) return null;
38
+ return `${sanitizeField(repoId)}:${normalizedKey}`;
39
+ }
40
+
41
+ export function bindIdempotencyScope(repoId, key, scopeParts) {
42
+ const normalizedKey = normalizeIdempotencyKey(key);
43
+ if (!normalizedKey) return null;
44
+ const bindingKey = idempotencyScopeKey(repoId, normalizedKey);
45
+ const serialized = JSON.stringify(scopeParts.map((part) => sanitizeField(String(part ?? ''))));
46
+ const prior = scopeBindings.get(bindingKey);
47
+ if (prior && prior !== serialized) {
48
+ throw Object.assign(new Error('Idempotency key scope conflict'), {
49
+ forgeError: forgeError(
50
+ ERROR_CODES.IDEMPOTENCY_CONFLICT,
51
+ 'idempotency key was already used with a different write scope',
52
+ ),
53
+ });
54
+ }
55
+ scopeBindings.set(bindingKey, serialized);
56
+ return idempotencyFingerprint(normalizedKey, scopeParts);
57
+ }
58
+
59
+ export function idempotencyPacketFields(fingerprint, { reusedExisting = false, ambiguousAfterWrite = false } = {}) {
60
+ const fields = {};
61
+ if (fingerprint) fields.idempotency_fingerprint = fingerprint;
62
+ if (reusedExisting) {
63
+ fields.reused_existing = true;
64
+ } else {
65
+ fields.created = true;
66
+ }
67
+ if (ambiguousAfterWrite) fields.ambiguous_after_write = true;
68
+ return fields;
69
+ }