@remogram/core 0.1.0-beta.1 → 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.
- package/auth-classes.js +73 -0
- package/branch-protection.js +155 -0
- package/caps.js +162 -14
- package/change-request-merge-execute.js +148 -0
- package/check-diagnostics.js +92 -0
- package/check-pagination.js +216 -0
- package/config-schema.js +21 -0
- package/contracts/envelope.js +26 -2
- package/contracts/errors.js +14 -2
- package/contracts/forge-error-fields.js +124 -0
- package/contracts/observer-fact-inventory.js +64 -0
- package/contracts/semantic-diff-facts.js +168 -0
- package/cr-comments.js +93 -0
- package/cr-files.js +62 -0
- package/cr-inventory-cursor.js +64 -0
- package/cr-inventory.js +136 -0
- package/cr-open.js +38 -0
- package/effective-write-policy.js +68 -0
- package/forge-changes-cursor.js +88 -0
- package/forge-changes.js +181 -0
- package/forge-identity.js +42 -0
- package/git-args.js +19 -0
- package/git-local.js +20 -0
- package/http.js +36 -2
- package/idempotency.js +69 -0
- package/index.js +255 -3
- package/issue-open.js +50 -0
- package/merge-blockers.js +68 -0
- package/merge-plan-forge.js +63 -0
- package/merge-plan.js +82 -0
- package/merge-policy.js +55 -0
- package/open-pull-list.js +256 -0
- package/operator-config.js +260 -0
- package/package.json +1 -1
- package/path-allowlist.js +114 -0
- package/pr-head-reconcile.js +38 -0
- package/provider-health.js +93 -0
- package/ref-inventory.js +98 -0
- package/resolve.js +53 -4
- package/status-set.js +92 -0
- package/stub-provider.js +11 -8
- package/whoami.js +114 -0
- package/write-config.js +63 -0
- package/write-field-policy.js +93 -0
- package/write-readiness.js +91 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { ERROR_CODES } from './contracts/errors.js';
|
|
2
|
+
import { DEFAULT_CHECK_STATUS_PAGE_SIZE, MAX_CHECK_STATUS_PAGES } from './caps.js';
|
|
3
|
+
import { resolvePaginatedEntryCount } from './open-pull-list.js';
|
|
4
|
+
|
|
5
|
+
function isOversizedIngestError(err) {
|
|
6
|
+
return err?.forgeError?.code === ERROR_CODES.OVERSIZED_RAW_OUTPUT;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function withPerPageParam(url, limit) {
|
|
10
|
+
const parsed = new URL(url);
|
|
11
|
+
parsed.searchParams.set('per_page', String(limit));
|
|
12
|
+
return parsed.toString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function withLimitParam(url, limit) {
|
|
16
|
+
const parsed = new URL(url);
|
|
17
|
+
parsed.searchParams.set('limit', String(limit));
|
|
18
|
+
return parsed.toString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Retry fetch with halved page size when raw ingest exceeds cap.
|
|
23
|
+
* @template T
|
|
24
|
+
* @param {(url: string) => Promise<T>} fetchFn
|
|
25
|
+
* @param {(limit: number) => string} buildUrl
|
|
26
|
+
* @param {number} [initialLimit]
|
|
27
|
+
*/
|
|
28
|
+
export async function fetchWithIngestPageBackoff(
|
|
29
|
+
fetchFn,
|
|
30
|
+
buildUrl,
|
|
31
|
+
initialLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
32
|
+
) {
|
|
33
|
+
let limit = initialLimit;
|
|
34
|
+
while (true) {
|
|
35
|
+
try {
|
|
36
|
+
return await fetchFn(buildUrl(limit));
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (isOversizedIngestError(err) && limit > 1) {
|
|
39
|
+
limit = Math.max(1, Math.floor(limit / 2));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fetch one offset page with ingest-cap backoff.
|
|
49
|
+
* @template T
|
|
50
|
+
* @param {(opts: { page: number, limit: number }) => Promise<unknown[]>} fetchPage
|
|
51
|
+
* @param {number} page
|
|
52
|
+
* @param {number} initialLimit
|
|
53
|
+
* @returns {Promise<{ items: unknown[], usedLimit: number }>}
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchPageWithIngestBackoff(fetchPage, page, initialLimit) {
|
|
56
|
+
let usedLimit = initialLimit;
|
|
57
|
+
const items = await fetchWithIngestPageBackoff(
|
|
58
|
+
async (limit) => {
|
|
59
|
+
usedLimit = limit;
|
|
60
|
+
const pageItems = await fetchPage({ page, limit });
|
|
61
|
+
return Array.isArray(pageItems) ? pageItems : [];
|
|
62
|
+
},
|
|
63
|
+
(limit) => limit,
|
|
64
|
+
initialLimit,
|
|
65
|
+
);
|
|
66
|
+
return { items, usedLimit };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* When a full page lands on maxPages, probe one item on the next page to distinguish
|
|
71
|
+
* end-of-list from truncation.
|
|
72
|
+
* @template T
|
|
73
|
+
* @param {(opts: { page: number, limit: number }) => Promise<unknown[]>} fetchPage
|
|
74
|
+
* @param {number} page
|
|
75
|
+
* @param {number} maxPages
|
|
76
|
+
* @returns {Promise<boolean>} true when list is truncated (more items exist)
|
|
77
|
+
*/
|
|
78
|
+
async function probeNextPageHasItems(fetchPage, page, maxPages) {
|
|
79
|
+
if (page > maxPages) return true;
|
|
80
|
+
const { items: probeItems } = await fetchPageWithIngestBackoff(fetchPage, page + 1, 1);
|
|
81
|
+
return probeItems.length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Offset/limit check-status pagination with ingest-cap backoff.
|
|
86
|
+
* @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize?: number, maxPages?: number }} opts
|
|
87
|
+
*/
|
|
88
|
+
export async function paginateCheckStatusPages({
|
|
89
|
+
fetchPage,
|
|
90
|
+
pageSize = DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
91
|
+
maxPages = MAX_CHECK_STATUS_PAGES,
|
|
92
|
+
}) {
|
|
93
|
+
const all = [];
|
|
94
|
+
let truncated = false;
|
|
95
|
+
let activeLimit = pageSize;
|
|
96
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
97
|
+
const { items: pageItems, usedLimit } = await fetchPageWithIngestBackoff(
|
|
98
|
+
fetchPage,
|
|
99
|
+
page,
|
|
100
|
+
activeLimit,
|
|
101
|
+
);
|
|
102
|
+
activeLimit = usedLimit;
|
|
103
|
+
all.push(...pageItems);
|
|
104
|
+
if (pageItems.length < usedLimit) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
if (page === maxPages) {
|
|
108
|
+
truncated = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { items: all, truncated };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Offset/limit open-list pagination with ingest-cap backoff and optional list cap.
|
|
117
|
+
* listLimit bounds request size per page; callers slice returned items when enforcing a hard cap.
|
|
118
|
+
* @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize: number, listLimit?: number | null, maxPages?: number, maxPagesTruncatesWithLimit?: boolean, retainMax?: number | null, trustedEntryCount?: number | null, seededFirstPage?: { items: unknown[], usedLimit: number } | null, startPage?: number, suppressFinalPageProbe?: boolean }} opts
|
|
119
|
+
* @returns {Promise<{ items: unknown[], list_truncated: boolean, walked_count: number, entry_count?: number }>}
|
|
120
|
+
*/
|
|
121
|
+
export async function paginateOffsetListPages({
|
|
122
|
+
fetchPage,
|
|
123
|
+
pageSize,
|
|
124
|
+
listLimit = null,
|
|
125
|
+
maxPages = MAX_CHECK_STATUS_PAGES,
|
|
126
|
+
maxPagesTruncatesWithLimit = false,
|
|
127
|
+
retainMax = null,
|
|
128
|
+
trustedEntryCount = null,
|
|
129
|
+
seededFirstPage = null,
|
|
130
|
+
startPage = 1,
|
|
131
|
+
suppressFinalPageProbe = false,
|
|
132
|
+
}) {
|
|
133
|
+
const all = [];
|
|
134
|
+
let entryCount = 0;
|
|
135
|
+
let listTruncated = false;
|
|
136
|
+
let activeLimit = pageSize;
|
|
137
|
+
|
|
138
|
+
async function afterPage(page, items, usedLimit) {
|
|
139
|
+
entryCount += items.length;
|
|
140
|
+
if (retainMax != null) {
|
|
141
|
+
const space = Math.max(retainMax - all.length, 0);
|
|
142
|
+
if (space > 0) all.push(...items.slice(0, space));
|
|
143
|
+
} else {
|
|
144
|
+
all.push(...items);
|
|
145
|
+
}
|
|
146
|
+
if (items.length < usedLimit) {
|
|
147
|
+
if (
|
|
148
|
+
trustedEntryCount != null &&
|
|
149
|
+
trustedEntryCount > entryCount &&
|
|
150
|
+
page === 1 &&
|
|
151
|
+
page < maxPages
|
|
152
|
+
) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (listLimit != null && all.length >= listLimit) {
|
|
158
|
+
listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (listLimit != null) {
|
|
162
|
+
if (maxPagesTruncatesWithLimit && page === maxPages) {
|
|
163
|
+
listTruncated = true;
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
} else if (page === maxPages) {
|
|
167
|
+
if (suppressFinalPageProbe) {
|
|
168
|
+
listTruncated = items.length >= usedLimit;
|
|
169
|
+
} else {
|
|
170
|
+
listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let page = startPage;
|
|
178
|
+
if (seededFirstPage && startPage === 1) {
|
|
179
|
+
const { items, usedLimit } = seededFirstPage;
|
|
180
|
+
activeLimit = usedLimit;
|
|
181
|
+
if (await afterPage(1, items, usedLimit)) {
|
|
182
|
+
return {
|
|
183
|
+
items: all,
|
|
184
|
+
list_truncated: listTruncated,
|
|
185
|
+
walked_count: entryCount,
|
|
186
|
+
...(retainMax != null || trustedEntryCount != null
|
|
187
|
+
? { entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount) }
|
|
188
|
+
: {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
page = 2;
|
|
192
|
+
activeLimit = pageSize;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (; page <= maxPages; page += 1) {
|
|
196
|
+
const remaining = listLimit != null ? Math.max(listLimit - all.length, 0) : activeLimit;
|
|
197
|
+
if (listLimit != null && remaining === 0) break;
|
|
198
|
+
const requestLimit = listLimit != null ? Math.min(activeLimit, remaining) : activeLimit;
|
|
199
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(fetchPage, page, requestLimit);
|
|
200
|
+
activeLimit = usedLimit;
|
|
201
|
+
if (await afterPage(page, items, usedLimit)) {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
items: all,
|
|
208
|
+
list_truncated: listTruncated,
|
|
209
|
+
walked_count: entryCount,
|
|
210
|
+
...(retainMax != null || trustedEntryCount != null
|
|
211
|
+
? {
|
|
212
|
+
entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount),
|
|
213
|
+
}
|
|
214
|
+
: {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
package/config-schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { writeCommandSchema } from './write-config.js';
|
|
2
3
|
|
|
3
4
|
const providerSchema = z.enum([
|
|
4
5
|
'gitea-api',
|
|
@@ -15,6 +16,18 @@ const repoSegmentSchema = z
|
|
|
15
16
|
message: 'owner/repo must not contain /, .., or %',
|
|
16
17
|
});
|
|
17
18
|
|
|
19
|
+
const fieldMaxBytesSchema = z.union([
|
|
20
|
+
z.number().int().positive(),
|
|
21
|
+
z.null(),
|
|
22
|
+
z.literal('none'),
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const forgeWritePolicySchema = z
|
|
26
|
+
.object({
|
|
27
|
+
field_max_bytes: fieldMaxBytesSchema.optional(),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
|
|
18
31
|
export const configSchema = z
|
|
19
32
|
.object({
|
|
20
33
|
version: z.literal('1'),
|
|
@@ -23,6 +36,14 @@ export const configSchema = z
|
|
|
23
36
|
owner: repoSegmentSchema,
|
|
24
37
|
repo: repoSegmentSchema,
|
|
25
38
|
baseUrl: z.string().url().optional(),
|
|
39
|
+
write_commands: z.array(writeCommandSchema).optional(),
|
|
40
|
+
forge_write_policy: forgeWritePolicySchema.optional(),
|
|
41
|
+
merge_policy: z
|
|
42
|
+
.object({
|
|
43
|
+
allow_missing_checks: z.boolean().optional(),
|
|
44
|
+
allow_pending_checks: z.boolean().optional(),
|
|
45
|
+
})
|
|
46
|
+
.optional(),
|
|
26
47
|
})
|
|
27
48
|
.strict();
|
|
28
49
|
|
package/contracts/envelope.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sanitizeField } from '../caps.js';
|
|
2
|
+
import { normalizeForgeErrorFields } from './forge-error-fields.js';
|
|
2
3
|
|
|
3
4
|
export const SCHEMA_VERSION = 1;
|
|
4
5
|
|
|
@@ -12,6 +13,16 @@ export const PACKET_TYPES = {
|
|
|
12
13
|
PROVIDER_CAPABILITIES: 'provider_capabilities',
|
|
13
14
|
PROVIDER_DOCTOR: 'provider_doctor',
|
|
14
15
|
FORGE_ERROR: 'forge_error',
|
|
16
|
+
CHANGE_REQUEST_OPENED: 'change_request_opened',
|
|
17
|
+
ISSUE_OPENED: 'issue_opened',
|
|
18
|
+
COMMIT_STATUS_SET: 'commit_status_set',
|
|
19
|
+
PROVIDER_IDENTITY: 'provider_identity',
|
|
20
|
+
BRANCH_PROTECTION: 'branch_protection',
|
|
21
|
+
CR_FILES: 'cr_files',
|
|
22
|
+
CR_COMMENTS: 'cr_comments',
|
|
23
|
+
FORGE_CHANGES: 'forge_changes',
|
|
24
|
+
CR_MERGED: 'cr_merged',
|
|
25
|
+
CR_MERGE_BLOCKED: 'cr_merge_blocked',
|
|
15
26
|
};
|
|
16
27
|
|
|
17
28
|
export const FORBIDDEN_PACKET_KEYS = new Set([
|
|
@@ -29,7 +40,7 @@ function assertNoForbiddenKeys(value) {
|
|
|
29
40
|
}
|
|
30
41
|
for (const [key, nested] of Object.entries(value)) {
|
|
31
42
|
if (FORBIDDEN_PACKET_KEYS.has(key)) {
|
|
32
|
-
throw new Error(`Forbidden
|
|
43
|
+
throw new Error(`Forbidden workflow/planning-tool key in remogram output: ${key}`);
|
|
33
44
|
}
|
|
34
45
|
assertNoForbiddenKeys(nested);
|
|
35
46
|
}
|
|
@@ -49,17 +60,30 @@ export function forgePacket(type, context, body = {}, error = null) {
|
|
|
49
60
|
ok: error == null,
|
|
50
61
|
};
|
|
51
62
|
|
|
63
|
+
delete packet.base_url;
|
|
64
|
+
if (context.baseUrl) {
|
|
65
|
+
packet.base_url = context.baseUrl;
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
if (error) {
|
|
53
69
|
packet.error_code = error.code;
|
|
54
70
|
packet.error_message = sanitizeField(error.message);
|
|
55
71
|
if (error.status != null) packet.error_status = error.status;
|
|
72
|
+
if (error.fields != null && typeof error.fields === 'object') {
|
|
73
|
+
assertNoForbiddenKeys(error.fields);
|
|
74
|
+
const trustedFields = normalizeForgeErrorFields(error.code, error.fields);
|
|
75
|
+
if (trustedFields != null) {
|
|
76
|
+
Object.assign(packet, trustedFields);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
return packet;
|
|
59
82
|
}
|
|
60
83
|
|
|
61
84
|
export function forgeErrorPacket(context, error, type = PACKET_TYPES.FORGE_ERROR) {
|
|
62
|
-
|
|
85
|
+
const body = error?.fields != null && typeof error.fields === 'object' ? error.fields : {};
|
|
86
|
+
return forgePacket(type, context, body, error);
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
export function unknownForgeContext() {
|
package/contracts/errors.js
CHANGED
|
@@ -11,9 +11,21 @@ export const ERROR_CODES = {
|
|
|
11
11
|
CONFIG_NOT_FOUND: 'config_not_found',
|
|
12
12
|
INVALID_ARGS: 'invalid_args',
|
|
13
13
|
API_ERROR: 'api_error',
|
|
14
|
+
PR_NOT_OPEN: 'pr_not_open',
|
|
14
15
|
REMOTE_INFER_FAILED: 'remote_infer_failed',
|
|
16
|
+
WRITE_NOT_CONFIGURED: 'write_not_configured',
|
|
17
|
+
IDEMPOTENCY_SCAN_INCOMPLETE: 'idempotency_scan_incomplete',
|
|
18
|
+
IDEMPOTENCY_CONFLICT: 'idempotency_conflict',
|
|
19
|
+
INVENTORY_LIST_INCOMPLETE: 'inventory_list_incomplete',
|
|
20
|
+
MERGE_BLOCKED: 'merge_blocked',
|
|
21
|
+
MERGE_ENDPOINT_FAILED: 'merge_endpoint_failed',
|
|
15
22
|
};
|
|
16
23
|
|
|
17
|
-
export function forgeError(code, message, status = null) {
|
|
18
|
-
return {
|
|
24
|
+
export function forgeError(code, message, status = null, fields = null) {
|
|
25
|
+
return {
|
|
26
|
+
code,
|
|
27
|
+
message,
|
|
28
|
+
...(status != null ? { status } : {}),
|
|
29
|
+
...(fields != null && typeof fields === 'object' ? { fields } : {}),
|
|
30
|
+
};
|
|
19
31
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ERROR_CODES } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/** Top-level packet keys forge error fields must never override. */
|
|
4
|
+
const FORBIDDEN_ERROR_FIELD_KEYS = new Set([
|
|
5
|
+
'type',
|
|
6
|
+
'schema_version',
|
|
7
|
+
'provider_id',
|
|
8
|
+
'remote_name',
|
|
9
|
+
'repo_id',
|
|
10
|
+
'base_url',
|
|
11
|
+
'observed_at',
|
|
12
|
+
'ok',
|
|
13
|
+
'error_code',
|
|
14
|
+
'error_message',
|
|
15
|
+
'error_status',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** Trusted body fields allowed per forge error code. */
|
|
19
|
+
export const FORGE_ERROR_FIELD_ALLOWLIST = Object.freeze({
|
|
20
|
+
[ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE]: ['idempotency_scan'],
|
|
21
|
+
[ERROR_CODES.INVENTORY_LIST_INCOMPLETE]: ['inventory_list'],
|
|
22
|
+
[ERROR_CODES.CONFIG_INVALID]: [
|
|
23
|
+
'reason',
|
|
24
|
+
'field',
|
|
25
|
+
'expected',
|
|
26
|
+
'actual',
|
|
27
|
+
'discovered_via',
|
|
28
|
+
'operator_config_path',
|
|
29
|
+
'remediation',
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function assertPositiveInteger(name, value) {
|
|
34
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
35
|
+
throw new Error(`Invalid forge error field ${name}: must be a positive integer`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateIdempotencyScan(scan) {
|
|
40
|
+
if (scan == null || typeof scan !== 'object' || Array.isArray(scan)) {
|
|
41
|
+
throw new Error('Invalid forge error field idempotency_scan: must be an object');
|
|
42
|
+
}
|
|
43
|
+
const keys = Object.keys(scan).sort();
|
|
44
|
+
const expected = ['max_pages', 'page_size', 'pages'];
|
|
45
|
+
if (keys.length !== expected.length || !expected.every((k) => keys.includes(k))) {
|
|
46
|
+
throw new Error('Invalid forge error field idempotency_scan: unexpected keys');
|
|
47
|
+
}
|
|
48
|
+
assertPositiveInteger('idempotency_scan.pages', scan.pages);
|
|
49
|
+
assertPositiveInteger('idempotency_scan.max_pages', scan.max_pages);
|
|
50
|
+
assertPositiveInteger('idempotency_scan.page_size', scan.page_size);
|
|
51
|
+
return { idempotency_scan: scan };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateInventoryList(list) {
|
|
55
|
+
if (list == null || typeof list !== 'object' || Array.isArray(list)) {
|
|
56
|
+
throw new Error('Invalid forge error field inventory_list: must be an object');
|
|
57
|
+
}
|
|
58
|
+
const keys = Object.keys(list).sort();
|
|
59
|
+
if (keys.length !== 1 || keys[0] !== 'entry_count') {
|
|
60
|
+
throw new Error('Invalid forge error field inventory_list: unexpected keys');
|
|
61
|
+
}
|
|
62
|
+
assertPositiveInteger('inventory_list.entry_count', list.entry_count);
|
|
63
|
+
return { inventory_list: list };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateConfigInvalidFields(fields) {
|
|
67
|
+
const allowed = FORGE_ERROR_FIELD_ALLOWLIST[ERROR_CODES.CONFIG_INVALID];
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const key of allowed) {
|
|
70
|
+
if (fields[key] == null) continue;
|
|
71
|
+
if (typeof fields[key] !== 'string') {
|
|
72
|
+
throw new Error(`Invalid forge error field ${key}: must be a string`);
|
|
73
|
+
}
|
|
74
|
+
out[key] = fields[key];
|
|
75
|
+
}
|
|
76
|
+
return Object.keys(out).length ? out : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate and normalize trusted forge_error body fields before packet merge.
|
|
81
|
+
* @param {string} code
|
|
82
|
+
* @param {Record<string, unknown> | null | undefined} fields
|
|
83
|
+
* @returns {Record<string, unknown> | null}
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeForgeErrorFields(code, fields) {
|
|
86
|
+
if (fields == null) return null;
|
|
87
|
+
if (typeof fields !== 'object' || Array.isArray(fields)) {
|
|
88
|
+
throw new Error('Invalid forge error fields: must be an object');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const keys = Object.keys(fields);
|
|
92
|
+
if (keys.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
for (const key of keys) {
|
|
95
|
+
if (FORBIDDEN_ERROR_FIELD_KEYS.has(key)) {
|
|
96
|
+
throw new Error(`Forge error fields cannot override packet field ${key}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const allowlist = FORGE_ERROR_FIELD_ALLOWLIST[code];
|
|
101
|
+
if (!allowlist) {
|
|
102
|
+
throw new Error(`Forge error code ${code} does not allow trusted fields`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const key of keys) {
|
|
106
|
+
if (!allowlist.includes(key)) {
|
|
107
|
+
throw new Error(`Forge error field ${key} is not allowed for code ${code}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (fields.idempotency_scan != null) {
|
|
112
|
+
return validateIdempotencyScan(fields.idempotency_scan);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (fields.inventory_list != null) {
|
|
116
|
+
return validateInventoryList(fields.inventory_list);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (code === ERROR_CODES.CONFIG_INVALID) {
|
|
120
|
+
return validateConfigInvalidFields(fields);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return fields;
|
|
124
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remogram fact requirements for observer and semantic-diff consumer snapshots.
|
|
3
|
+
* Observer proto today captures remogram repo status only; downstream consumers
|
|
4
|
+
* may compose additional read-only fact packets listed here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { FACT_INVENTORY_PACKET_TYPES, V1_READ_PLAN_COMMANDS } from './semantic-diff-facts.js';
|
|
8
|
+
|
|
9
|
+
/** CLI/MCP surface entries observer consumers may call (read-only). */
|
|
10
|
+
export const OBSERVER_REMOGRAM_COMMANDS = Object.freeze([
|
|
11
|
+
{ command: 'repo status', mcp_tool: 'repo_status', read_only: true, observer_proto: true },
|
|
12
|
+
{ command: 'refs inventory', mcp_tool: 'ref_inventory', read_only: true, observer_proto: false },
|
|
13
|
+
{ command: 'cr inventory', mcp_tool: 'cr_inventory', read_only: true, observer_proto: false },
|
|
14
|
+
{ command: 'refs compare', mcp_tool: 'ref_compare', read_only: true, observer_proto: false },
|
|
15
|
+
{ command: 'pr view', mcp_tool: 'pr_status', read_only: true, observer_proto: false },
|
|
16
|
+
{ command: 'pr checks', mcp_tool: 'pr_checks', read_only: true, observer_proto: false },
|
|
17
|
+
{ command: 'merge plan', mcp_tool: 'merge_plan', read_only: true, observer_proto: false },
|
|
18
|
+
{ command: 'sync plan', mcp_tool: 'sync_plan', read_only: true, observer_proto: false },
|
|
19
|
+
{ command: 'provider capabilities', mcp_tool: 'provider_capabilities', read_only: true, observer_proto: false },
|
|
20
|
+
{ command: 'doctor', mcp_tool: 'doctor', read_only: true, observer_proto: false },
|
|
21
|
+
{ command: 'whoami', mcp_tool: 'whoami', read_only: true, observer_proto: false },
|
|
22
|
+
{ command: 'branch protection', mcp_tool: 'branch_protection', read_only: true, observer_proto: false },
|
|
23
|
+
{ command: 'cr files', mcp_tool: 'cr_files', read_only: true, observer_proto: false },
|
|
24
|
+
{ command: 'cr comments', mcp_tool: 'cr_comments', read_only: true, observer_proto: false },
|
|
25
|
+
{ command: 'forge changes', mcp_tool: 'forge_changes', read_only: true, observer_proto: false },
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Fact inventory packet types for semantic-diff / branch-workcycle composition. */
|
|
29
|
+
export const OBSERVER_FACT_INVENTORY_PACKETS = Object.freeze([
|
|
30
|
+
{
|
|
31
|
+
packet_type: FACT_INVENTORY_PACKET_TYPES.REF_INVENTORY,
|
|
32
|
+
command: 'refs inventory',
|
|
33
|
+
mcp_tool: 'ref_inventory',
|
|
34
|
+
read_only: true,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
packet_type: FACT_INVENTORY_PACKET_TYPES.CR_INVENTORY_SLICE,
|
|
38
|
+
command: 'cr inventory',
|
|
39
|
+
mcp_tool: 'cr_inventory',
|
|
40
|
+
read_only: true,
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Commands captured directly by observer-snapshot.sh today. */
|
|
45
|
+
export function observerProtoRemogramCommands() {
|
|
46
|
+
return OBSERVER_REMOGRAM_COMMANDS.filter((entry) => entry.observer_proto);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Extended fact commands for semantic-diff inventory beyond the proto script. */
|
|
50
|
+
export function semanticDiffFactCommands() {
|
|
51
|
+
return OBSERVER_REMOGRAM_COMMANDS.filter((entry) => !entry.observer_proto);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** All v1 read/plan commands remain authoritative alongside fact inventory. */
|
|
55
|
+
export function allObserverEligibleCommands() {
|
|
56
|
+
return V1_READ_PLAN_COMMANDS.map((command) => {
|
|
57
|
+
const entry = OBSERVER_REMOGRAM_COMMANDS.find((c) => c.command === command);
|
|
58
|
+
return {
|
|
59
|
+
command,
|
|
60
|
+
mcp_tool: entry?.mcp_tool ?? null,
|
|
61
|
+
read_only: entry?.read_only ?? true,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|